Projects
This commit is contained in:
@@ -4,9 +4,11 @@ import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
|
||||
import { appRegistryMetas as chatMetas } from '../apps/Chat';
|
||||
import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer';
|
||||
import { appRegistryMetas as workspaceMetas } from '../apps/Workspaces';
|
||||
import { appRegistryMetas as projectMetas } from '../apps/Projects';
|
||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas];
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...workspaceMetas, ...projectMetas, ...chatHistoryMetas];
|
||||
|
||||
export const AppRegistry = () => {
|
||||
useAppRegistry(apps);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { usePiChat, EmbeddableChat } from '../Chat';
|
||||
|
||||
export type SelectedSession = {
|
||||
id: string;
|
||||
model?: string | null;
|
||||
} | null;
|
||||
|
||||
const CHANNEL = 'chat:selected-session';
|
||||
|
||||
type ChatLocationState = {
|
||||
initialMessage?: string;
|
||||
prefillInput?: string;
|
||||
model?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
attachmentIds?: string[];
|
||||
images?: { filename: string; dataUrl: string }[];
|
||||
} | null;
|
||||
|
||||
type DetailBarProps = {
|
||||
sessionTitle: string | undefined;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
onDelete: (() => void) | undefined;
|
||||
};
|
||||
|
||||
function DetailBar({ sessionTitle, isConnected, isGenerating, onDelete }: DetailBarProps) {
|
||||
return (
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<div className="flex items-center gap-1">
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 dark:text-foreground/70 truncate px-3">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{!isConnected ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||
) : isGenerating ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||
) : (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SessionChatProps = {
|
||||
sessionId: string;
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||
|
||||
const chat = usePiChat(sessionId, model, { replaceUrl: false });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
sessionTitle={sessionTitle}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onDelete={async () => {
|
||||
await deleteSession(sessionId);
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
sessionId={sessionId}
|
||||
initialModel={model ?? undefined}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewChat() {
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
const chat = usePiChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.sessionId) {
|
||||
setSelected({ id: chat.sessionId, model: chat.model });
|
||||
}
|
||||
}, [chat.sessionId]);
|
||||
|
||||
const initialMessage = locationState?.initialMessage
|
||||
? {
|
||||
text: locationState.initialMessage,
|
||||
attachmentIds: locationState.attachmentIds,
|
||||
images: locationState.images,
|
||||
cwd: locationState.cwd,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
sessionTitle={undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onDelete={undefined}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
sessionId={undefined}
|
||||
initialModel={locationState?.model ?? undefined}
|
||||
initialMessage={initialMessage}
|
||||
defaultInput={locationState?.prefillInput ?? ''}
|
||||
cwd={locationState?.cwd}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewChatPanel() {
|
||||
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
if (selected && selected.id !== 'new') {
|
||||
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||
}
|
||||
|
||||
return <NewChat key="new" />;
|
||||
}
|
||||
|
||||
export const ChatDetailPanel = () => {
|
||||
const [selected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
Select a session to view
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selected.id === 'new') {
|
||||
return <NewChatPanel key="new" />;
|
||||
}
|
||||
|
||||
return <SessionChat key={selected.id} sessionId={selected.id} model={selected.model} />;
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
|
||||
type CreateGroupDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function toSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function CreateGroupDialog({ onClose }: CreateGroupDialogProps) {
|
||||
const { createGroup } = useChatGroups();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = toSlug(name);
|
||||
if (!slug) {
|
||||
setError('Name must contain at least one alphanumeric character');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await createGroup(name.trim(), slug, description.trim() || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create group');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: React.MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-duck-dark/10 dark:border-foreground/10">
|
||||
<h2 className="text-lg font-semibold text-duck-dark dark:text-foreground">Create Group</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="group-name" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="group-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Work Projects"
|
||||
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{name && (
|
||||
<p className="mt-1 text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Slug: <span className="font-mono">{toSlug(name) || '(invalid)'}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="group-description" className="block text-sm font-medium text-duck-dark/70 dark:text-foreground/70 mb-1.5">
|
||||
Description <span className="text-duck-dark/40 dark:text-foreground/40">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="group-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add a description for this group..."
|
||||
className="w-full px-3 py-2 rounded-md border border-duck-dark/20 dark:border-foreground/20 bg-background text-duck-dark dark:text-foreground placeholder:text-duck-dark/40 dark:placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-duck-teal/50 resize-none"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-sm font-medium text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/10 dark:hover:bg-foreground/10 rounded-md transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
import type { GroupEntry } from '../Chat';
|
||||
|
||||
type GroupContextMenuProps = {
|
||||
group: GroupEntry;
|
||||
};
|
||||
|
||||
export function GroupContextMenu({ group }: GroupContextMenuProps) {
|
||||
const { updateGroup, deleteGroup } = useChatGroups();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(group.name);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
async function handleRename() {
|
||||
if (editName.trim() && editName !== group.name) {
|
||||
await updateGroup(group.slug, { name: editName.trim() });
|
||||
}
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteGroup(group.slug);
|
||||
setShowDeleteConfirm(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleButtonClick(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
setIsOpen(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 ml-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename();
|
||||
if (e.key === 'Escape') {
|
||||
setEditName(group.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
|
||||
autoFocus
|
||||
onBlur={handleRename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative ml-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
|
||||
{!showDeleteConfirm ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
Delete "{group.name}"? Sessions will be ungrouped.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-duck-dark/10 dark:bg-foreground/10 hover:bg-duck-dark/20 dark:hover:bg-foreground/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex-1 px-3 py-1.5 text-xs rounded-md bg-red-500 hover:bg-red-600 text-white transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import type { SessionEntry } from '../Chat';
|
||||
|
||||
type SessionContextMenuProps = {
|
||||
session: SessionEntry;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
export function SessionContextMenu({ session, onDelete }: SessionContextMenuProps) {
|
||||
const { groups, moveSession } = useChatGroups();
|
||||
const { renameSession } = useChatSessions();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showMoveMenu, setShowMoveMenu] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState(session.title);
|
||||
|
||||
async function handleMove(groupSlug: string | null) {
|
||||
await moveSession(session.id, groupSlug);
|
||||
setIsOpen(false);
|
||||
setShowMoveMenu(false);
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (editTitle.trim() && editTitle !== session.title) {
|
||||
await renameSession(session.id, editTitle.trim());
|
||||
}
|
||||
setIsRenaming(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function handleButtonClick(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
setIsOpen(false);
|
||||
setShowMoveMenu(false);
|
||||
}
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 ml-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename();
|
||||
if (e.key === 'Escape') {
|
||||
setEditTitle(session.title);
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-sm border border-duck-dark/20 dark:border-foreground/20 rounded bg-background focus:outline-none focus:ring-2 focus:ring-duck-teal/50"
|
||||
autoFocus
|
||||
onBlur={handleRename}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
className="p-1 rounded-md hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={handleBackdropClick} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-[160px] bg-background border border-duck-dark/10 dark:border-foreground/10 rounded-md shadow-lg overflow-hidden">
|
||||
{!showMoveMenu ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowMoveMenu(true)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
Move to group
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRenaming(true);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(session.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-red-500/10 text-red-600 dark:text-red-400 transition-colors text-left"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-2 text-xs font-medium text-duck-dark/50 dark:text-foreground/50 border-b border-duck-dark/10 dark:border-foreground/10">
|
||||
Move to:
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleMove(null)}
|
||||
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
Ungrouped
|
||||
</button>
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.slug}
|
||||
onClick={() => handleMove(group.slug)}
|
||||
className="w-full px-3 py-2 text-sm hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-duck-dark/10 dark:border-foreground/10">
|
||||
<button
|
||||
onClick={() => setShowMoveMenu(false)}
|
||||
className="w-full px-3 py-2 text-xs text-duck-dark/50 dark:text-foreground/50 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors text-left"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Plus, MessageSquare, Folder, ChevronRight, FolderPlus } from 'lucide-react';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { useChatSessions } from 'state/useChatSessions';
|
||||
import { useChatGroups } from 'state/useChatGroups';
|
||||
import type { SelectedSession } from './ChatDetailPanel';
|
||||
import { CreateGroupDialog } from './CreateGroupDialog';
|
||||
import { GroupContextMenu } from './GroupContextMenu';
|
||||
import { SessionContextMenu } from './SessionContextMenu';
|
||||
|
||||
export const SessionList = () => {
|
||||
const { sessions, deleteSession } = useChatSessions();
|
||||
const { groups } = useChatGroups();
|
||||
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||
|
||||
const scrolledRef = useRef(false);
|
||||
const selectedRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node && !scrolledRef.current) {
|
||||
scrolledRef.current = true;
|
||||
node.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scrolledRef.current = false;
|
||||
}, [selected?.id]);
|
||||
|
||||
const toggleGroup = (slug: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(slug) ? next.delete(slug) : next.add(slug);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const ungrouped = sessions.filter(s => !s.groupSlug);
|
||||
const grouped = groups.map(g => ({
|
||||
...g,
|
||||
sessions: sessions.filter(s => s.groupSlug === g.slug)
|
||||
}));
|
||||
|
||||
const handleSelect = (session: (typeof sessions)[number]) => {
|
||||
setSelected({ id: session.id, model: session.model ?? null });
|
||||
window.history.replaceState(null, '', `/chat/${session.id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (selected?.id === id) {
|
||||
setSelected(null);
|
||||
window.history.replaceState(null, '', '/chat');
|
||||
}
|
||||
await deleteSession(id);
|
||||
};
|
||||
|
||||
const renderSession = (session: (typeof sessions)[number]) => {
|
||||
const isSelected = selected?.id === session.id;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
className={`group flex items-center rounded-lg border transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
|
||||
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelect(session)}
|
||||
className="flex-1 flex items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">
|
||||
{session.title}
|
||||
<span className="ml-1.5 font-mono text-xs font-normal text-duck-dark/25 dark:text-foreground/25">
|
||||
({session.id.slice(0, 8)})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
{new Date(session.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
{session.model && (
|
||||
<div className="text-xs text-duck-teal/70 dark:text-duck-teal/60 truncate">
|
||||
{session.model.includes('/') ? session.model.replace('/', ' - ') : session.model}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="shrink-0 mr-2">
|
||||
<SessionContextMenu session={session} onDelete={handleDelete} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
|
||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-dark/10 hover:bg-duck-dark/20 dark:bg-foreground/10 dark:hover:bg-foreground/20 text-duck-dark dark:text-foreground cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
New Group
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelected({ id: 'new' });
|
||||
window.history.replaceState(null, '', '/chat/new');
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
|
||||
{sessions.length === 0 && (
|
||||
<div className="text-center py-16 text-duck-dark/30 dark:text-foreground/30 text-sm">
|
||||
No sessions yet. Start a new chat!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ungrouped sessions */}
|
||||
{ungrouped.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{ungrouped.map(renderSession)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grouped sessions */}
|
||||
{grouped.map((group) => (
|
||||
<div key={group.slug} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors">
|
||||
<div onClick={() => toggleGroup(group.slug)} className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${!collapsed.has(group.slug) ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Folder className="h-4 w-4 text-duck-teal" />
|
||||
<span className="font-medium text-sm text-duck-dark/70 dark:text-foreground/70">{group.name}</span>
|
||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">({group.sessionCount})</span>
|
||||
</div>
|
||||
<GroupContextMenu group={group} />
|
||||
</div>
|
||||
{!collapsed.has(group.slug) && (
|
||||
<div className="ml-6 space-y-1.5">
|
||||
{group.sessions.map(renderSession)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCreateGroup && <CreateGroupDialog onClose={() => setShowCreateGroup(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,26 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MessageSquare, List } from 'lucide-react';
|
||||
import { SessionList } from './SessionList';
|
||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||
|
||||
export { SessionBar } from './SessionBar';
|
||||
export { SessionList };
|
||||
export { ChatDetailPanel };
|
||||
export type { SelectedSession } from './ChatDetailPanel';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'chat-session-list',
|
||||
name: 'Sessions',
|
||||
icon: List,
|
||||
component: SessionList,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'chat-detail',
|
||||
name: 'Chat',
|
||||
icon: MessageSquare,
|
||||
component: ChatDetailPanel,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,10 +9,11 @@ import { useFileBrowserApp } from './useFileBrowserApp';
|
||||
|
||||
type FileBrowserAppProps = {
|
||||
basePath?: string;
|
||||
rootOverride?: string;
|
||||
};
|
||||
|
||||
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath);
|
||||
export const FileBrowserApp = ({ basePath = '/', rootOverride }: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride);
|
||||
const { handleNavigate } = fileBrowserManager;
|
||||
|
||||
return (
|
||||
|
||||
+2
-1
@@ -5,5 +5,6 @@ const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
||||
|
||||
export const FileBrowserPanelWrapper = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return <FileBrowserApp basePath={cwdToPath(cwd)} />;
|
||||
const basePath = cwdToPath(cwd);
|
||||
return <FileBrowserApp basePath={basePath} rootOverride={basePath !== '/' ? 'home' : undefined} />;
|
||||
};
|
||||
|
||||
@@ -6,12 +6,16 @@ import { useTasks, type TaskSummary } from '../useTasks';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
export const useFileBrowserApp = (basePath: string) => {
|
||||
export const useFileBrowserApp = (basePath: string, rootOverride?: string) => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||
const [currentPath, setCurrentPath] = useUserState<string>('files/currentPath', '/');
|
||||
const [globalPath, setGlobalPath] = useUserState<string>('files/currentPath', '/');
|
||||
const [localPath, setLocalPath] = useState(basePath);
|
||||
const scoped = basePath !== '/';
|
||||
const currentPath = scoped ? localPath : globalPath;
|
||||
const setCurrentPath = scoped ? setLocalPath : setGlobalPath;
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
||||
@@ -37,7 +41,7 @@ export const useFileBrowserApp = (basePath: string) => {
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewPath = searchParams.get('view');
|
||||
const files = useFilesAPI(homeRoot);
|
||||
const files = useFilesAPI(rootOverride ?? homeRoot);
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { FolderKanban, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
} from './constants';
|
||||
|
||||
const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
||||
'landing-page': 'Landing Page',
|
||||
'website': 'Website',
|
||||
'app': 'App',
|
||||
};
|
||||
|
||||
export const ProjectListApp = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleting, setDeleting] = useState<ProjectDefinition | null>(null);
|
||||
const isProjectsPage = location.pathname === '/projects';
|
||||
const filtered = search
|
||||
? projects.filter((p) => {
|
||||
const q = search.toLowerCase();
|
||||
return [p.name, p.id, p.description ?? '', p.cwd ?? '', p.projectType].some((field) =>
|
||||
field.toLowerCase().includes(q),
|
||||
);
|
||||
})
|
||||
: projects;
|
||||
|
||||
const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setSelected(null);
|
||||
setCreating(false);
|
||||
setEditing(p.id);
|
||||
setName(p.name);
|
||||
setDescription(p.description ?? '');
|
||||
setTemplateIdx(p.templateIdx ?? 0);
|
||||
setProjectType(p.projectType);
|
||||
setHasBackend(p.hasBackend ?? false);
|
||||
setHasAuth(p.hasAuth ?? false);
|
||||
};
|
||||
|
||||
const handleDelete = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setDeleting(p);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleting) return;
|
||||
// Optimistic: remove from cache immediately
|
||||
const current = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||
const optimistic = { ...current };
|
||||
optimistic['projects'] = (optimistic['projects'] as ProjectDefinition[]).filter((p) => p.id !== deleting.id);
|
||||
delete optimistic[`proj-layout-${deleting.id}`];
|
||||
delete optimistic[`proj-terminals-${deleting.id}`];
|
||||
delete optimistic[`proj-host-terminals-${deleting.id}`];
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], optimistic);
|
||||
|
||||
if (selected === deleting.id) setSelected(null);
|
||||
setDeleting(null);
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${deleting.id}`]: null })
|
||||
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleClick = (p: ProjectDefinition) => {
|
||||
if (isProjectsPage) {
|
||||
setSelected(p.id);
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
} else {
|
||||
navigate(`/projects/${p.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||
<Link
|
||||
to="/projects"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400"
|
||||
>
|
||||
<FolderKanban className="h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
if (!isProjectsPage) navigate('/projects');
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-500/90 text-white transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3 pt-2">
|
||||
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search projects"
|
||||
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-emerald-500/30 focus:border-emerald-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-2">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||
isProjectsPage && selected === p.id
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-white/80 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
onClick={() => handleClick(p)}
|
||||
>
|
||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{p.name}</span>
|
||||
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
|
||||
{isProjectsPage && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleEdit(ev, p)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-emerald-400 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleDelete(ev, p)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
||||
{search ? 'No matches' : 'No projects yet'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,549 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useWorkspacesState } from 'state/useWorkspacesState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateSlug, slugify } from 'helpers/slug';
|
||||
import { useAppRegistry } from '../../AppRegistry';
|
||||
import {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
NEW_PROJ_PREVIEW_LAYOUT,
|
||||
} from './constants';
|
||||
|
||||
// --- Layout Templates ---
|
||||
|
||||
let tplCounter = 0;
|
||||
const tplUid = () => `ptpl-${++tplCounter}`;
|
||||
|
||||
type LayoutTemplate = {
|
||||
name: string;
|
||||
layout: () => LayoutNode;
|
||||
};
|
||||
|
||||
const templates: LayoutTemplate[] = [
|
||||
{
|
||||
name: 'Single',
|
||||
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
||||
},
|
||||
{
|
||||
name: '2 Columns',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Main + Side',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Sidebar',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: '2x2 Grid',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Cols + Bottom',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Template Thumbnails ---
|
||||
|
||||
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
|
||||
if (node.type === 'panel') {
|
||||
return <div className="h-full w-full bg-emerald-500/15 border border-emerald-500/20 rounded-sm" />;
|
||||
}
|
||||
const isH = node.direction === 'horizontal';
|
||||
return (
|
||||
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
|
||||
{node.children.map((child) => (
|
||||
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
|
||||
<ThumbnailNode node={child.node} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateCell = ({ index }: { index: number }) => {
|
||||
const tpl = templates[index]!;
|
||||
const [selected, setSelected] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const node = tpl.layout();
|
||||
const isSelected = selected === index;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(index)}
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
||||
<ThumbnailNode node={node} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${isSelected ? 'text-emerald-400' : 'text-duck-dark/60'}`}>{tpl.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Template Picker (3x2 workspace) ---
|
||||
|
||||
const tplPanelLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'ptpl-root',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'ptpl-row-0',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ptpl-0', appType: 'ptpl-0' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-1', appType: 'ptpl-1' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-2', appType: 'ptpl-2' }, size: 33.34 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'ptpl-row-1',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ptpl-3', appType: 'ptpl-3' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-4', appType: 'ptpl-4' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-5', appType: 'ptpl-5' }, size: 33.34 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tplRegistry = Object.fromEntries(
|
||||
templates.map((tpl, i) => [`ptpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
|
||||
);
|
||||
|
||||
const TemplatePanel = () => (
|
||||
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} />
|
||||
);
|
||||
|
||||
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||
|
||||
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||
{ value: 'landing-page', label: 'Landing Page' },
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'app', label: 'App' },
|
||||
];
|
||||
|
||||
const DetailsPanel = () => {
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [projectType, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [hasBackend, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [hasAuth, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||
<Type className="h-3 w-3" />
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
placeholder="My Project"
|
||||
autoFocus
|
||||
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||
<FileText className="h-3 w-3" />
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="What is this project for?"
|
||||
rows={3}
|
||||
className="resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50">Project Type</label>
|
||||
<div className="flex gap-2">
|
||||
{PROJECT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setProjectType(opt.value)}
|
||||
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-colors cursor-pointer ${
|
||||
projectType === opt.value
|
||||
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-400'
|
||||
: 'border-duck-dark/20 text-duck-dark/50 hover:border-duck-dark/30'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{projectType === 'app' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBackend}
|
||||
onChange={(ev) => setHasBackend(ev.target.checked)}
|
||||
className="accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/60">Has Backend</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAuth}
|
||||
onChange={(ev) => setHasAuth(ev.target.checked)}
|
||||
className="accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Create ---
|
||||
|
||||
const CreatePanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [previewLayout] = useGlobal<LayoutNode>(NEW_PROJ_PREVIEW_LAYOUT, createDefaultLayout());
|
||||
const [projectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [hasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
const slug = isEditing ? editingId : slugify(name.trim()) || generateSlug();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const desc = description.trim();
|
||||
|
||||
const meta = {
|
||||
name: trimmed,
|
||||
description: desc || undefined,
|
||||
projectType,
|
||||
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||
templateIdx,
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${editingId}`]: meta, [`proj-layout-${editingId}`]: previewLayout })
|
||||
.then((res) => queryClient.setQueryData(['WORKSPACES_STATE'], res))
|
||||
.catch(() => {});
|
||||
|
||||
setEditingId(null);
|
||||
setSelected(editingId);
|
||||
} else {
|
||||
const existingIds = new Set(projects.map((p) => p.id));
|
||||
let id = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
|
||||
client
|
||||
.patch('/workspaces', { [`proj-meta-${id}`]: meta, [`proj-layout-${id}`]: previewLayout })
|
||||
.then((res) => {
|
||||
queryClient.setQueryData(['WORKSPACES_STATE'], res);
|
||||
navigate(`/projects/${id}`);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
|
||||
<Rocket className="h-8 w-8 text-emerald-500/40" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
|
||||
<p className="text-xs font-mono text-duck-dark/40 mt-1">~/Projects/{slug}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim()}
|
||||
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{isEditing ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button
|
||||
onClick={() => navigate(`/projects/${editingId}`)}
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 mr-1" />
|
||||
Open Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Live Template Preview (full interactive sub-workspace) ---
|
||||
|
||||
const PREVIEW_LAYOUT_KEY = ['USE_GLOBAL', NEW_PROJ_PREVIEW_LAYOUT];
|
||||
|
||||
const TemplatePreviewInner = ({ templateIdx }: { templateIdx: number }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [layout, setLocalLayout] = useState<LayoutNode>(() => {
|
||||
const initial = templates[templateIdx]!.layout();
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, initial);
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleLayoutChange = (next: LayoutNode) => {
|
||||
setLocalLayout(next);
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, next);
|
||||
};
|
||||
|
||||
return <WorkspaceLayout layout={layout} onLayoutChange={handleLayoutChange} />;
|
||||
};
|
||||
|
||||
const TemplatePreviewPanel = () => {
|
||||
const [selectedIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
return <TemplatePreviewInner key={selectedIdx} templateIdx={selectedIdx} />;
|
||||
};
|
||||
|
||||
// --- New Project Layout ---
|
||||
|
||||
const newProjLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'new-proj-root',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'new-proj-top',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-proj-details', appType: 'new-proj-details' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'new-proj-template', appType: 'new-proj-template' }, size: 70 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'new-proj-bottom',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-proj-placeholder', appType: 'new-proj-placeholder' }, size: 70 },
|
||||
{ node: { type: 'panel', id: 'new-proj-create', appType: 'new-proj-create' }, size: 30 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const NewProjectForm = () => {
|
||||
const { registry } = useAppRegistry();
|
||||
const newProjRegistry = {
|
||||
...registry,
|
||||
'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
|
||||
'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
|
||||
'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||
'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||
};
|
||||
|
||||
return <WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} />;
|
||||
};
|
||||
|
||||
const ProjectPreviewEmpty = () => {
|
||||
const [creating, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [editingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
if (creating || editingId) return <NewProjectForm />;
|
||||
|
||||
const handleCreate = () => {
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<FolderKanban className="h-10 w-10 text-white/80" />
|
||||
<p className="text-sm text-white/80">Select a project or create a new one</p>
|
||||
<Button onClick={handleCreate} className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const ws = useWorkspacesState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
return <WorkspaceView workspace={ws} cwd={project.cwd} />;
|
||||
};
|
||||
|
||||
export const ProjectPreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const { value: projects } = useWorkspacesState<ProjectDefinition[]>('projects', []);
|
||||
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
||||
|
||||
if (!project) return <ProjectPreviewEmpty />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ProjectPreviewInner key={project.id} project={project} />
|
||||
<Link
|
||||
to={`/projects/${project.id}`}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
|
||||
>
|
||||
<ArrowRight className="h-16 w-16 text-emerald-500/0 group-hover:text-emerald-500/60 transition-colors" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export const SELECTED_PROJECT = 'SELECTED_PROJECT';
|
||||
export const CREATING_PROJECT = 'CREATING_PROJECT';
|
||||
export const EDITING_PROJECT = 'EDITING_PROJECT';
|
||||
export const NEW_PROJ_NAME = 'NEW_PROJ_NAME';
|
||||
export const NEW_PROJ_DESC = 'NEW_PROJ_DESC';
|
||||
export const NEW_PROJ_TEMPLATE = 'NEW_PROJ_TEMPLATE';
|
||||
export const NEW_PROJ_TYPE = 'NEW_PROJ_TYPE';
|
||||
export const NEW_PROJ_HAS_BACKEND = 'NEW_PROJ_HAS_BACKEND';
|
||||
export const NEW_PROJ_HAS_AUTH = 'NEW_PROJ_HAS_AUTH';
|
||||
export const NEW_PROJ_PREVIEW_LAYOUT = 'NEW_PROJ_PREVIEW_LAYOUT';
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { ProjectListApp } from './ProjectListApp';
|
||||
import { ProjectPreview } from './ProjectPreview';
|
||||
|
||||
export { ProjectListApp };
|
||||
export { ProjectPreview };
|
||||
export {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
NEW_PROJ_PREVIEW_LAYOUT,
|
||||
} from './constants';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'project-list',
|
||||
name: 'Projects',
|
||||
icon: FolderKanban,
|
||||
component: ProjectListApp,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'project-preview',
|
||||
name: 'Project Preview',
|
||||
icon: FolderKanban,
|
||||
component: ProjectPreview,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -37,7 +37,8 @@ export const CommandTerminalWrapper = ({ panelId, command, statePrefix }: Comman
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
|
||||
const cwdPath = cwd && cwd !== '~' ? `~/${cwd.replace(/^\//, '')}` : null;
|
||||
const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
||||
};
|
||||
|
||||
@@ -8,13 +8,14 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||
const { user } = useAuth();
|
||||
const { mode, toggle } = useTerminalMode(panelId);
|
||||
const isHost = mode === 'host';
|
||||
const Icon = isHost ? Monitor : TerminalSquare;
|
||||
const scoped = cwd !== '~';
|
||||
const Icon = isHost && !scoped ? Monitor : TerminalSquare;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
{user?.role === 'Super Admin' && (
|
||||
{user?.role === 'Super Admin' && !scoped && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
||||
@@ -9,7 +9,8 @@ const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const { mode } = useTerminalMode(panelId);
|
||||
const sandboxed = mode === 'sandboxed';
|
||||
const scoped = cwd !== '~';
|
||||
const sandboxed = scoped || mode === 'sandboxed';
|
||||
const stateKey = workspaceId ? `ws-terminals-${mode}-${workspaceId}` : `ws-terminals-${mode}-default`;
|
||||
const { value: terminals, setValue: setTerminals } = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
|
||||
@@ -5,7 +5,8 @@ export * from './AppRegistry';
|
||||
export { MessageList, MessageBubble, StreamingBubble, ToolActivity, QuestionActivity, ModelSelector, InputArea, ChatLauncher, AttachmentList, AttachButton, WebpageDialog, EmbeddableChat, usePiChat, ChatList, useSlashCommands, useChatSessions, useChatSession, useAttachments, useAudioRecording } from './apps/Chat';
|
||||
export type { UseEmbeddableChatType, UsePiChatType, UseChatSessionsType, UseChatSessionType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat';
|
||||
export * from './apps/Chat/types';
|
||||
export { SessionBar } from './apps/ChatHistory';
|
||||
export { SessionBar, SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
||||
export type { SelectedSession } from './apps/ChatHistory';
|
||||
export { CodeEditorView } from './apps/CodeEditor';
|
||||
export { useFilesAPI, useTasks, useRecentFiles, usePinnedFiles, FileBrowserApp, FileBrowserPanelWrapper, FileBrowserWidget, TaskRunnerModal } from './apps/FileBrowser';
|
||||
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
|
||||
@@ -14,3 +15,4 @@ export type { FileType } from './apps/FileViewer';
|
||||
export { TerminalView } from './apps/Terminal';
|
||||
export type { TerminalViewProps } from './apps/Terminal';
|
||||
export { WorkspaceListApp, WorkspacePreview, SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './apps/Workspaces';
|
||||
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
|
||||
|
||||
@@ -2,7 +2,6 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
|
||||
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
|
||||
export { useUserState } from './useUserState';
|
||||
export { useWorkspacesState } from './useWorkspacesState';
|
||||
export { useProjectsState } from './useProjectsState';
|
||||
export { usePiModels, useVisiblePiModels, modelKey } from './useModels';
|
||||
export type { ModelOption } from './useModels';
|
||||
export { useRecentModels } from './useRecentModels';
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './useSettings';
|
||||
|
||||
const QUERY_KEY = ['PROJECTS_STATE'];
|
||||
|
||||
export function useProjectsState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/user/projects-state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
clientRef.current
|
||||
.patch<UserState>('/user/projects-state', { [key]: newValue })
|
||||
.then((serverState) => {
|
||||
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue, isSuccess];
|
||||
}
|
||||
Reference in New Issue
Block a user