Compare commits
7 Commits
v1.6.4-aut
...
v1.7.0-dra
| Author | SHA1 | Date | |
|---|---|---|---|
| 934f739ca1 | |||
| e9895fd694 | |||
| 83c7d33f3c | |||
| c3415574d6 | |||
| 50a756aca1 | |||
| 3cb1ead5e2 | |||
| c750ce9e62 |
@@ -120,6 +120,15 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const { name, model, system_prompt } = parsed.data;
|
const { name, model, system_prompt } = parsed.data;
|
||||||
|
// Read the prior name so the post-update publish can skip no-op renames
|
||||||
|
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||||
|
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||||
|
// a concurrent rename in that gap would just mean one stale publish, which
|
||||||
|
// existing clients dedup by id.
|
||||||
|
const before = await sql<{ name: string }[]>`
|
||||||
|
SELECT name FROM sessions WHERE id = ${req.params.id}
|
||||||
|
`;
|
||||||
|
const priorName = before[0]?.name;
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET
|
SET
|
||||||
@@ -135,7 +144,7 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
const session = rows[0]!;
|
const session = rows[0]!;
|
||||||
if (name !== undefined) {
|
if (name !== undefined && session.name !== priorName) {
|
||||||
broker.publishUser('default', {
|
broker.publishUser('default', {
|
||||||
type: 'session_renamed',
|
type: 'session_renamed',
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||||
import { Send } from 'lucide-react';
|
import { Send } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
|
import {
|
||||||
|
flattenToMessage,
|
||||||
|
inferLanguage,
|
||||||
|
looksBinary,
|
||||||
|
MAX_FILE_SIZE_BYTES,
|
||||||
|
PASTE_INLINE_MAX_LINES,
|
||||||
|
type Attachment,
|
||||||
|
} from '@/lib/attachments';
|
||||||
import { AttachmentChip } from '@/components/AttachmentChip';
|
import { AttachmentChip } from '@/components/AttachmentChip';
|
||||||
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||||
|
import { DropOverlay } from '@/components/DropOverlay';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
|
const MAX_ATTACHMENTS = 10;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -24,6 +34,9 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
||||||
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||||
|
const dropRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const pasteCounterRef = useRef(0);
|
||||||
const [mentionState, setMentionState] = useState<{
|
const [mentionState, setMentionState] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
query: string;
|
query: string;
|
||||||
@@ -35,8 +48,8 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
|
|
||||||
function addAttachment(a: Attachment) {
|
function addAttachment(a: Attachment) {
|
||||||
setAttachments(prev => {
|
setAttachments(prev => {
|
||||||
if (prev.length >= 10) {
|
if (prev.length >= MAX_ATTACHMENTS) {
|
||||||
toast.error('Max 10 attachments per message');
|
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return [...prev, a];
|
return [...prev, a];
|
||||||
@@ -185,6 +198,162 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
|
|
||||||
const closeMention = useCallback(() => setMentionState(null), []);
|
const closeMention = useCallback(() => setMentionState(null), []);
|
||||||
|
|
||||||
|
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
|
||||||
|
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
|
||||||
|
// onDragLeave only clears the highlight when the cursor leaves the
|
||||||
|
// container, not when it crosses into a child element.
|
||||||
|
|
||||||
|
async function processDroppedFile(file: File) {
|
||||||
|
// Size gate
|
||||||
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||||
|
const mb = (file.size / (1024 * 1024)).toFixed(1);
|
||||||
|
toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Read once as ArrayBuffer so we can do byte-level binary detection
|
||||||
|
// before deciding whether to decode as text.
|
||||||
|
let buf: ArrayBuffer;
|
||||||
|
try {
|
||||||
|
buf = await file.arrayBuffer();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (looksBinary(buf)) {
|
||||||
|
toast.error(`${file.name} appears to be binary.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||||
|
addAttachment({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: 'file',
|
||||||
|
filename: file.name,
|
||||||
|
language: inferLanguage(file.name),
|
||||||
|
content: text,
|
||||||
|
source: 'drop',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFolderItem(item: DataTransferItem | undefined): boolean {
|
||||||
|
if (!item) return false;
|
||||||
|
// webkitGetAsEntry is non-standard but supported in Chromium + Safari.
|
||||||
|
// If unavailable, we conservatively treat the entry as a file.
|
||||||
|
const entry =
|
||||||
|
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
|
||||||
|
if (entry && entry.isDirectory) return true;
|
||||||
|
// Heuristic fallback: folders dragged from Finder have type === '' and
|
||||||
|
// a 0-byte File. The empty-type alone isn't reliable for files (some
|
||||||
|
// plaintext drops also lack a type), so we only flag when the entry
|
||||||
|
// explicitly says directory.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDroppedItems(dt: DataTransfer) {
|
||||||
|
// Snapshot items first because reading files inside the loop can
|
||||||
|
// detach the DataTransfer between awaits.
|
||||||
|
const itemsArray: { file: File | null; isFolder: boolean }[] = [];
|
||||||
|
if (dt.items && dt.items.length > 0) {
|
||||||
|
for (let i = 0; i < dt.items.length; i++) {
|
||||||
|
const it = dt.items[i];
|
||||||
|
if (!it || it.kind !== 'file') continue;
|
||||||
|
const folder = isFolderItem(it);
|
||||||
|
const file = folder ? null : it.getAsFile();
|
||||||
|
itemsArray.push({ file, isFolder: folder });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < dt.files.length; i++) {
|
||||||
|
const f = dt.files[i];
|
||||||
|
if (f) itemsArray.push({ file: f, isFolder: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingSlots = MAX_ATTACHMENTS - attachments.length;
|
||||||
|
let folderRejected = false;
|
||||||
|
for (const { file, isFolder } of itemsArray) {
|
||||||
|
if (isFolder) {
|
||||||
|
if (!folderRejected) {
|
||||||
|
toast.error('Folders are not supported');
|
||||||
|
folderRejected = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!file) continue;
|
||||||
|
if (remainingSlots <= 0) {
|
||||||
|
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await processDroppedFile(file);
|
||||||
|
remainingSlots -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(e: DragEvent<HTMLDivElement>) {
|
||||||
|
if (disabled || busy) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent<HTMLDivElement>) {
|
||||||
|
if (disabled || busy) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e: DragEvent<HTMLDivElement>) {
|
||||||
|
// Only clear when the cursor actually leaves the root container.
|
||||||
|
// relatedTarget is the element being entered; if it's inside the root,
|
||||||
|
// ignore — we're just crossing into a child.
|
||||||
|
const root = dropRootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const related = e.relatedTarget as Node | null;
|
||||||
|
if (related && root.contains(related)) return;
|
||||||
|
setIsDraggingOver(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent<HTMLDivElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingOver(false);
|
||||||
|
if (disabled || busy) return;
|
||||||
|
void handleDroppedItems(e.dataTransfer);
|
||||||
|
}
|
||||||
|
// ---- end Drag & drop -----------------------------------------------------
|
||||||
|
|
||||||
|
// ---- Paste-as-attachment (F2) -------------------------------------------
|
||||||
|
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
|
||||||
|
// inline content. Image pastes are rejected with a toast. If both text and
|
||||||
|
// image are present (e.g. screenshot tool that sets both), prefer text.
|
||||||
|
|
||||||
|
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
||||||
|
const cd = e.clipboardData;
|
||||||
|
if (!cd) return;
|
||||||
|
const text = cd.getData('text/plain');
|
||||||
|
const hasImage = Array.from(cd.items ?? []).some((it) =>
|
||||||
|
it.type.startsWith('image/'),
|
||||||
|
);
|
||||||
|
if (text) {
|
||||||
|
const lineCount = text.split('\n').length;
|
||||||
|
if (lineCount > PASTE_INLINE_MAX_LINES) {
|
||||||
|
e.preventDefault();
|
||||||
|
pasteCounterRef.current += 1;
|
||||||
|
addAttachment({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: 'paste',
|
||||||
|
filename: `pasted-${pasteCounterRef.current}.txt`,
|
||||||
|
language: 'plaintext',
|
||||||
|
content: text,
|
||||||
|
source: 'paste',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// <= threshold: let default paste insert inline.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasImage) {
|
||||||
|
e.preventDefault();
|
||||||
|
toast.error('Image paste is not supported. Drop a file or paste text.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- end Paste-as-attachment --------------------------------------------
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (mentionState?.open) return;
|
if (mentionState?.open) return;
|
||||||
// IME safety: never act on Enter while an IME composition is in flight
|
// IME safety: never act on Enter while an IME composition is in flight
|
||||||
@@ -228,7 +397,16 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
<div
|
||||||
|
ref={dropRootRef}
|
||||||
|
className="border-t relative"
|
||||||
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
|
onDragEnter={onDragEnter}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<DropOverlay visible={isDraggingOver} />
|
||||||
<div className="max-w-[1000px] mx-auto w-full">
|
<div className="max-w-[1000px] mx-auto w-full">
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||||||
@@ -248,6 +426,7 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={onPaste}
|
||||||
placeholder={
|
placeholder={
|
||||||
isMobile
|
isMobile
|
||||||
? 'Ask about this project. Tap send to submit.'
|
? 'Ask about this project. Tap send to submit.'
|
||||||
|
|||||||
18
apps/web/src/components/DropOverlay.tsx
Normal file
18
apps/web/src/components/DropOverlay.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual cue layered over the ChatInput while a drag is in progress.
|
||||||
|
// Pointer-events: none so the underlying drop handler still receives the
|
||||||
|
// drop event. Renders nothing when not visible (cheap and out of layout).
|
||||||
|
export function DropOverlay({ visible }: Props) {
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center rounded border-2 border-dashed border-primary bg-background/85"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-primary">Drop to attach</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,34 @@ export type Attachment = {
|
|||||||
source: '@' | 'line-select' | 'drop' | 'paste';
|
source: '@' | 'line-select' | 'drop' | 'paste';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// v1.7: caps shared between drag-drop and paste-as-attachment so both paths
|
||||||
|
// reject the same way. Match the existing 10-attachment cap in
|
||||||
|
// ChatInput.addAttachment.
|
||||||
|
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||||
|
export const PASTE_INLINE_MAX_LINES = 8;
|
||||||
|
|
||||||
|
// First-8KB null-byte scan. Returns true if the content looks binary.
|
||||||
|
// Accepts a string (post-decode), an ArrayBuffer (pre-decode), or a Uint8Array.
|
||||||
|
// For binary files like PNG, scanning bytes is more reliable than scanning
|
||||||
|
// post-UTF-8-decode strings because invalid sequences may be replaced rather
|
||||||
|
// than preserved.
|
||||||
|
export function looksBinary(content: string | ArrayBuffer | Uint8Array): boolean {
|
||||||
|
const SCAN_BYTES = 8192;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
const max = Math.min(content.length, SCAN_BYTES);
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
if (content.charCodeAt(i) === 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
|
||||||
|
const max = Math.min(bytes.length, SCAN_BYTES);
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
if (bytes[i] === 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export const LANG_MAP: Record<string, string> = {
|
export const LANG_MAP: Record<string, string> = {
|
||||||
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
|
||||||
mjs: 'javascript', cjs: 'javascript',
|
mjs: 'javascript', cjs: 'javascript',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Folder, RotateCcw } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AddProjectModal } from '@/components/AddProjectModal';
|
import { AddProjectModal } from '@/components/AddProjectModal';
|
||||||
@@ -8,6 +8,9 @@ import { api } from '@/api/client';
|
|||||||
import type { Project } from '@/api/types';
|
import type { Project } from '@/api/types';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { useSidebar } from '@/hooks/useSidebar';
|
import { useSidebar } from '@/hooks/useSidebar';
|
||||||
|
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||||
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||||
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const { data } = useSidebar();
|
const { data } = useSidebar();
|
||||||
@@ -15,6 +18,9 @@ export function Home() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [archived, setArchived] = useState<Project[] | null>(null);
|
const [archived, setArchived] = useState<Project[] | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const { setOpen: setSidebarOpen } = useSidebarDrawer();
|
||||||
|
const { toggle: toggleRightRail } = useRightRailDrawer();
|
||||||
|
const { isMobile } = useViewport();
|
||||||
|
|
||||||
const empty = data ? data.projects.length === 0 : false;
|
const empty = data ? data.projects.length === 0 : false;
|
||||||
|
|
||||||
@@ -70,8 +76,32 @@ export function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="w-full max-w-md space-y-6">
|
{isMobile && (
|
||||||
|
<header
|
||||||
|
className="border-b px-3 sm:px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm"
|
||||||
|
style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="inline-flex items-center justify-center -ml-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleRightRail}
|
||||||
|
className="inline-flex items-center justify-center -mr-1 ml-auto min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||||
|
aria-label="Toggle file browser"
|
||||||
|
>
|
||||||
|
<FolderTree className="size-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 flex flex-col items-center px-6 py-12 overflow-y-auto">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<>
|
<>
|
||||||
@@ -127,9 +157,10 @@ export function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
|
||||||
|
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
</div>
|
</div>
|
||||||
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
|
|
||||||
<CreateProjectModal open={createOpen} onOpenChange={setCreateOpen} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user