🚀 RELEASE: new chat
This commit is contained in:
parent
dbde31ec20
commit
c8fb0280ea
@ -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}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
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;
|
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user