🚀 RELEASE: new chat
This commit is contained in:
parent
dbde31ec20
commit
c8fb0280ea
@ -443,6 +443,8 @@ type ChatPanelProps = {
|
||||
fileContext?: FileContext;
|
||||
/** Compact mode for workspace sidebar (smaller UI, built-in session tabs). */
|
||||
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. */
|
||||
onFileChanged?: (newContent: string) => void;
|
||||
/** Called when active session changes (for external sidebar highlighting). */
|
||||
@ -456,6 +458,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
{
|
||||
fileContext,
|
||||
compact,
|
||||
sessionTitle,
|
||||
onFileChanged,
|
||||
onActiveSessionChange,
|
||||
onSessionsChange,
|
||||
@ -1155,7 +1158,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
}}
|
||||
>
|
||||
{currentSessionId
|
||||
? "Chat Session"
|
||||
? (sessionTitle || "Chat Session")
|
||||
: "New Chat"}
|
||||
</h2>
|
||||
<p
|
||||
@ -1384,6 +1387,26 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
"var(--color-chat-input-bg)",
|
||||
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
|
||||
ref={editorRef}
|
||||
|
||||
@ -7,10 +7,11 @@ import {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
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 {
|
||||
createFileMentionRenderer,
|
||||
@ -193,6 +194,10 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
const submitRef = useRef(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({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
@ -225,12 +230,61 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
}
|
||||
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 }) => {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!editor) {return;}
|
||||
@ -255,53 +309,6 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
|
||||
return () => el.removeEventListener("keydown", handleKeyDown);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
|
||||
@ -6,6 +6,7 @@ type BreadcrumbsProps = {
|
||||
};
|
||||
|
||||
export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||
const isAbsolute = path.startsWith("/");
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
@ -30,7 +31,7 @@ export function Breadcrumbs({ path, onNavigate }: BreadcrumbsProps) {
|
||||
</button>
|
||||
|
||||
{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;
|
||||
|
||||
return (
|
||||
|
||||
254
apps/web/app/components/workspace/chat-sessions-sidebar.tsx
Normal file
254
apps/web/app/components/workspace/chat-sessions-sidebar.tsx
Normal 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;
|
||||
}
|
||||
@ -50,6 +50,8 @@ type FileManagerTreeProps = {
|
||||
onNavigateUp?: () => void;
|
||||
/** Current browse directory (absolute path), or null when in workspace mode. */
|
||||
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) ---
|
||||
@ -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 }) {
|
||||
// Chat items use the chat bubble icon
|
||||
if (node.path.startsWith("~chats/") || node.path === "~chats") {
|
||||
@ -365,6 +378,7 @@ function DraggableNode({
|
||||
onContextMenu,
|
||||
compact,
|
||||
dragOverPath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
@ -381,16 +395,19 @@ function DraggableNode({
|
||||
onContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
|
||||
compact?: boolean;
|
||||
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 isExpandable = hasChildren || node.type === "folder" || node.type === "object";
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object");
|
||||
const isExpanded = isWorkspaceRoot ? false : expandedPaths.has(node.path);
|
||||
const isActive = activePath === node.path;
|
||||
const isSelected = selectedPath === node.path;
|
||||
const isRenaming = renamingPath === node.path;
|
||||
const isSysFile = isSystemFile(node.path);
|
||||
const isVirtual = isVirtualNode(node);
|
||||
const isProtected = isSysFile || isVirtual;
|
||||
const isProtected = isSysFile || isVirtual || isWorkspaceRoot;
|
||||
const isDragOver = dragOverPath === node.path && isExpandable;
|
||||
|
||||
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"
|
||||
style={{
|
||||
paddingLeft: `${depth * 16 + 8}px`,
|
||||
background: showDropHighlight
|
||||
background: isWorkspaceRoot
|
||||
? "var(--color-accent-light)"
|
||||
: isSelected
|
||||
? "var(--color-surface-hover)"
|
||||
: isActive
|
||||
: showDropHighlight
|
||||
? "var(--color-accent-light)"
|
||||
: isSelected
|
||||
? "var(--color-surface-hover)"
|
||||
: "transparent",
|
||||
color: isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)",
|
||||
outline: showDropHighlight ? "1px dashed var(--color-accent)" : "none",
|
||||
: isActive
|
||||
? "var(--color-surface-hover)"
|
||||
: "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",
|
||||
borderRadius: "6px",
|
||||
borderRadius: isWorkspaceRoot ? "8px" : "6px",
|
||||
marginTop: isWorkspaceRoot ? "2px" : undefined,
|
||||
marginBottom: isWorkspaceRoot ? "2px" : undefined,
|
||||
}}
|
||||
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)";
|
||||
}
|
||||
}}
|
||||
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";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center" style={{ opacity: isExpandable ? 1 : 0 }}>
|
||||
{/* 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, cursor: isExpandable ? "pointer" : undefined }}
|
||||
onClick={isExpandable ? (e) => { e.stopPropagation(); onToggleExpand(node.path); } : undefined}
|
||||
>
|
||||
{isExpandable && <ChevronIcon open={isExpanded} />}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
<span className="flex-shrink-0 flex items-center" style={{ color: typeColor(node) }}>
|
||||
<NodeIcon node={node} open={isExpanded} />
|
||||
<span className="flex-shrink-0 flex items-center" style={{ color: isWorkspaceRoot ? "var(--color-accent)" : typeColor(node) }}>
|
||||
{isWorkspaceRoot ? <WorkspaceGridIcon /> : <NodeIcon node={node} open={isExpanded} />}
|
||||
</span>
|
||||
|
||||
{/* Label or rename input */}
|
||||
@ -509,8 +542,16 @@ function DraggableNode({
|
||||
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
|
||||
)}
|
||||
|
||||
{/* Lock badge for system/virtual files */}
|
||||
{isProtected && !compact && (
|
||||
{/* Workspace badge for the workspace root entry point */}
|
||||
{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">
|
||||
<LockBadge />
|
||||
</span>
|
||||
@ -549,6 +590,7 @@ function DraggableNode({
|
||||
onContextMenu={onContextMenu}
|
||||
compact={compact}
|
||||
dragOverPath={dragOverPath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -617,7 +659,7 @@ function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
|
||||
|
||||
// --- 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 [selectedPath, setSelectedPath] = 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}
|
||||
compact={compact}
|
||||
dragOverPath={dragOverPath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -27,6 +27,8 @@ type WorkspaceSidebarProps = {
|
||||
onGoHome?: () => void;
|
||||
/** Called when a file/folder is selected from the search dropdown. */
|
||||
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() {
|
||||
@ -398,6 +400,7 @@ export function WorkspaceSidebar({
|
||||
onNavigateUp,
|
||||
onGoHome,
|
||||
onFileSearchSelect,
|
||||
workspaceRoot,
|
||||
}: WorkspaceSidebarProps) {
|
||||
const isBrowsing = browseDir != null;
|
||||
|
||||
@ -507,15 +510,16 @@ export function WorkspaceSidebar({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileManagerTree
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
onRefresh={onRefresh}
|
||||
parentDir={parentDir}
|
||||
onNavigateUp={onNavigateUp}
|
||||
browseDir={browseDir}
|
||||
/>
|
||||
<FileManagerTree
|
||||
tree={tree}
|
||||
activePath={activePath}
|
||||
onSelect={onSelect}
|
||||
onRefresh={onRefresh}
|
||||
parentDir={parentDir}
|
||||
onNavigateUp={onNavigateUp}
|
||||
browseDir={browseDir}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -525,12 +529,13 @@ export function WorkspaceSidebar({
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<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"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<HomeIcon />
|
||||
Home
|
||||
ironclaw.sh
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@ -13,6 +13,7 @@ import { CodeViewer } from "../components/workspace/code-viewer";
|
||||
import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer";
|
||||
import { DatabaseViewer } from "../components/workspace/database-viewer";
|
||||
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
||||
import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar";
|
||||
import { EmptyState } from "../components/workspace/empty-state";
|
||||
import { ReportViewer } from "../components/charts/report-viewer";
|
||||
import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
|
||||
@ -466,28 +467,14 @@ function WorkspacePageInner() {
|
||||
[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)
|
||||
const enhancedTree = useMemo(() => {
|
||||
if (browseDir) {
|
||||
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) => {
|
||||
if (!job.enabled) {return "\u25CB";} // circle outline
|
||||
if (job.state.runningAtMs) {return "\u25CF";} // filled circle
|
||||
@ -511,8 +498,8 @@ function WorkspacePageInner() {
|
||||
children: cronChildren.length > 0 ? cronChildren : undefined,
|
||||
};
|
||||
|
||||
return [...tree, chatsFolder, cronFolder];
|
||||
}, [tree, sessions, cronJobs, browseDir]);
|
||||
return [...tree, cronFolder];
|
||||
}, [tree, cronJobs, browseDir]);
|
||||
|
||||
// Compute the effective parentDir for ".." navigation.
|
||||
// In browse mode: use browseParentDir from the API.
|
||||
@ -650,12 +637,26 @@ function WorkspacePageInner() {
|
||||
setContent({ kind: "none" });
|
||||
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);
|
||||
if (node) {
|
||||
void loadContent(node);
|
||||
handleNodeSelect(node);
|
||||
}
|
||||
},
|
||||
[tree, loadContent],
|
||||
[tree, handleNodeSelect, setBrowseDir],
|
||||
);
|
||||
|
||||
// Navigate to an object by name (used by relation links)
|
||||
@ -758,6 +759,13 @@ function WorkspacePageInner() {
|
||||
router.replace("/workspace", { scroll: false });
|
||||
}, [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)
|
||||
const showMainChat = !activePath || content.kind === "none";
|
||||
|
||||
@ -777,6 +785,7 @@ function WorkspacePageInner() {
|
||||
onNavigateUp={handleNavigateUp}
|
||||
onGoHome={handleGoHome}
|
||||
onFileSearchSelect={handleFileSearchSelect}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
@ -833,15 +842,32 @@ function WorkspacePageInner() {
|
||||
<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">
|
||||
<ChatPanel
|
||||
ref={chatRef}
|
||||
onActiveSessionChange={(id) => {
|
||||
setActiveSessionId(id);
|
||||
<>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<ChatPanel
|
||||
ref={chatRef}
|
||||
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 */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user