feat: add "Choose from Server" attachment picker to claim/preauth forms and chatbot
Native file inputs can't be pointed at a default server folder for security reasons, so instead the Browse buttons on the Claim/PreAuth/Select Procedures forms and the AI chatbot's attach button now also offer picking from previously saved Cloud Storage attachments (e.g. Copy Agent screenshots) via a new server-side picker modal, scoped per-patient on the claim forms and unscoped (recent across all patients) in the chatbot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,49 @@ router.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/claims/patient-attachments?patientId=123
|
||||||
|
// Lists this patient's previously saved Cloud Storage "Attachments" (e.g. Copy Agent screenshots)
|
||||||
|
// so they can be picked and re-attached to a claim/preauth without re-uploading from disk.
|
||||||
|
router.get(
|
||||||
|
"/patient-attachments",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const patientId = Number(req.query.patientId);
|
||||||
|
if (!patientId || isNaN(patientId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid patientId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await storage.listPatientCategoryCloudFiles(patientId, "Attachments");
|
||||||
|
return res.json({ error: false, data: files });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[patient-attachments]", err);
|
||||||
|
return res.status(500).json({ error: "Failed to list attachments", message: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/claims/recent-attachments?limit=20
|
||||||
|
// Lists recently saved Cloud Storage "Attachments" across all patients (e.g. Copy Agent
|
||||||
|
// screenshots), for use in contexts (like the AI chatbot) that don't have a patient in scope yet.
|
||||||
|
router.get(
|
||||||
|
"/recent-attachments",
|
||||||
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await storage.listRecentAttachmentFiles(limit);
|
||||||
|
return res.json({ error: false, data: files });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[recent-attachments]", err);
|
||||||
|
return res.status(500).json({ error: "Failed to list attachments", message: err?.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/mh-provider-login",
|
"/mh-provider-login",
|
||||||
async (req: Request, res: Response): Promise<any> => {
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export interface IStorage {
|
|||||||
patientId: number,
|
patientId: number,
|
||||||
category: string
|
category: string
|
||||||
): Promise<CloudFile[]>;
|
): Promise<CloudFile[]>;
|
||||||
|
listRecentAttachmentFiles(limit?: number): Promise<CloudFile[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------- Implementation ------------------------------- */
|
/* ------------------------------- Implementation ------------------------------- */
|
||||||
@@ -663,6 +664,25 @@ export const cloudStorageStorage: IStorage = {
|
|||||||
return files.map(serializeFile) as unknown as CloudFile[];
|
return files.map(serializeFile) as unknown as CloudFile[];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listRecentAttachmentFiles(limit: number = 20) {
|
||||||
|
const files = await db.cloudFile.findMany({
|
||||||
|
where: { folder: { name: "Attachments" }, isComplete: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
mimeType: true,
|
||||||
|
fileSize: true,
|
||||||
|
folderId: true,
|
||||||
|
isComplete: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return files.map(serializeFile) as unknown as CloudFile[];
|
||||||
|
},
|
||||||
|
|
||||||
// --- STREAM ---
|
// --- STREAM ---
|
||||||
async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) {
|
async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) {
|
||||||
const file = await db.cloudFile.findUnique({
|
const file = await db.cloudFile.findUnique({
|
||||||
|
|||||||
@@ -2320,6 +2320,7 @@ export function ClaimForm({
|
|||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
|
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
|
||||||
maxFiles={10}
|
maxFiles={10}
|
||||||
|
patientId={patientId}
|
||||||
/>
|
/>
|
||||||
{form.uploadedFiles.length > 0 && (
|
{form.uploadedFiles.length > 0 && (
|
||||||
<ul className="text-sm text-gray-700 list-disc ml-6">
|
<ul className="text-sm text-gray-700 list-disc ml-6">
|
||||||
@@ -2883,6 +2884,7 @@ export function ClaimForm({
|
|||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
|
acceptedFileTypes="application/pdf,image/jpeg,image/jpg,image/png,image/webp"
|
||||||
maxFiles={10}
|
maxFiles={10}
|
||||||
|
patientId={patientId}
|
||||||
/>
|
/>
|
||||||
{form.uploadedFiles.length > 0 && (
|
{form.uploadedFiles.length > 0 && (
|
||||||
<ul className="text-sm text-gray-700 list-disc ml-6">
|
<ul className="text-sm text-gray-700 list-disc ml-6">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Upload, X, FilePlus } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
|
||||||
|
|
||||||
export type MultipleFileUploadZoneHandle = {
|
export type MultipleFileUploadZoneHandle = {
|
||||||
getFiles: () => File[];
|
getFiles: () => File[];
|
||||||
@@ -25,6 +26,9 @@ interface FileUploadZoneProps {
|
|||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
//OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 }
|
//OPTIONAL: per-mime (or wildcard) map in MB: { "application/pdf": 10, "image/*": 2 }
|
||||||
maxFileSizeByType?: Record<string, number>;
|
maxFileSizeByType?: Record<string, number>;
|
||||||
|
// OPTIONAL: when set, shows a "Choose from Server" option listing this patient's
|
||||||
|
// previously saved Cloud Storage attachments (e.g. Copy Agent screenshots)
|
||||||
|
patientId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MultipleFileUploadZone = forwardRef<
|
export const MultipleFileUploadZone = forwardRef<
|
||||||
@@ -39,12 +43,14 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
maxFiles = 10,
|
maxFiles = 10,
|
||||||
maxFileSizeMB = 10, // default fallback per-file size (MB)
|
maxFileSizeMB = 10, // default fallback per-file size (MB)
|
||||||
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
|
maxFileSizeByType, // optional per-type overrides, e.g. { "application/pdf": 10, "image/*": 2 }
|
||||||
|
patientId,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
|
||||||
|
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const parsedAccept = acceptedFileTypes
|
const parsedAccept = acceptedFileTypes
|
||||||
@@ -222,7 +228,7 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
[onFilesChange]
|
[onFilesChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFiles = (files: FileList | null) => {
|
const handleFiles = (files: FileList | File[] | null) => {
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
|
|
||||||
const newFiles = Array.from(files).filter(validateFile);
|
const newFiles = Array.from(files).filter(validateFile);
|
||||||
@@ -439,6 +445,7 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
Or click to browse files
|
Or click to browse files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -447,11 +454,33 @@ export const MultipleFileUploadZone = forwardRef<
|
|||||||
handleBrowseClick();
|
handleBrowseClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Browse files
|
Choose from This Computer
|
||||||
</Button>
|
</Button>
|
||||||
|
{patientId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsServerPickerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choose from Server
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{patientId ? (
|
||||||
|
<ServerAttachmentsPickerModal
|
||||||
|
open={isServerPickerOpen}
|
||||||
|
onClose={() => setIsServerPickerOpen(false)}
|
||||||
|
patientId={patientId}
|
||||||
|
onFilesSelected={(files) => handleFiles(files)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { FileText, File as FileIcon, Image as ImageIcon, Loader2, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { CloudFile } from "@repo/db/types";
|
||||||
|
|
||||||
|
interface ServerAttachmentsPickerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
// When set, lists only this patient's saved attachments. When omitted, lists
|
||||||
|
// recently saved attachments across all patients (used where no patient is in scope yet).
|
||||||
|
patientId?: number;
|
||||||
|
onFilesSelected: (files: File[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(mime: string | undefined | null) {
|
||||||
|
if (mime?.startsWith("image/")) return ImageIcon;
|
||||||
|
if (mime === "application/pdf") return FileText;
|
||||||
|
return FileIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentThumbnail({ file }: { file: CloudFile }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file.mimeType?.startsWith("image/")) return;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const res = await apiRequest("GET", `/api/cloud-storage/files/${file.id}/content`);
|
||||||
|
if (!res.ok || cancelled) return;
|
||||||
|
const blob = await res.blob();
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
if (!cancelled) setSrc(objectUrl);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [file.id, file.mimeType]);
|
||||||
|
|
||||||
|
if (file.mimeType?.startsWith("image/")) {
|
||||||
|
return src ? (
|
||||||
|
<img src={src} alt={file.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = fileIcon(file.mimeType);
|
||||||
|
return <Icon className="h-8 w-8 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerAttachmentsPickerModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
patientId,
|
||||||
|
onFilesSelected,
|
||||||
|
}: ServerAttachmentsPickerModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const endpoint = patientId
|
||||||
|
? `/api/claims/patient-attachments?patientId=${patientId}`
|
||||||
|
: `/api/claims/recent-attachments?limit=40`;
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery<CloudFile[]>({
|
||||||
|
queryKey: ["/api/claims/attachments-picker", patientId ?? "recent"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiRequest("GET", endpoint);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || json.error) {
|
||||||
|
throw new Error(json.message || "Failed to load attachments");
|
||||||
|
}
|
||||||
|
return (json.data ?? []) as CloudFile[];
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setSelectedIds(new Set());
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const files = data ?? [];
|
||||||
|
|
||||||
|
const toggleSelected = (id: number) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSelected = async () => {
|
||||||
|
if (selectedIds.size === 0) return;
|
||||||
|
setIsAdding(true);
|
||||||
|
try {
|
||||||
|
const selectedFiles = files.filter((f) => selectedIds.has(f.id as number));
|
||||||
|
const built = await Promise.all(
|
||||||
|
selectedFiles.map(async (f) => {
|
||||||
|
const res = await apiRequest("GET", `/api/cloud-storage/files/${f.id}/content`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
return new File([blob], f.name, { type: f.mimeType || blob.type });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onFilesSelected(built);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to add files",
|
||||||
|
description: "Could not fetch one or more attachments from the server.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/40"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isAdding) onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-2xl mx-4 bg-white rounded-lg shadow-lg flex flex-col max-h-[80vh]">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<h3 className="text-lg font-medium">Choose from Server Attachments</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{patientId
|
||||||
|
? "Previously saved screenshots and files for this patient."
|
||||||
|
: "Recently saved screenshots and files across all patients."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 overflow-y-auto flex-1" style={{ minHeight: "200px" }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-sm text-red-500 text-center py-8">
|
||||||
|
Failed to load attachments.
|
||||||
|
</p>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
{patientId
|
||||||
|
? "No screenshots saved yet for this patient."
|
||||||
|
: "No screenshots saved yet."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isSelected = selectedIds.has(file.id as number);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={file.id}
|
||||||
|
onClick={() => toggleSelected(file.id as number)}
|
||||||
|
className={`relative border rounded-md overflow-hidden text-left ${
|
||||||
|
isSelected ? "ring-2 ring-primary border-primary" : "hover:bg-muted/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="h-24 flex items-center justify-center bg-gray-50">
|
||||||
|
<AttachmentThumbnail file={file} />
|
||||||
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-xs font-medium truncate">{file.name}</p>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-1 right-1 bg-primary text-primary-foreground rounded-full p-0.5">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 p-4 border-t">
|
||||||
|
<Button variant="ghost" type="button" onClick={onClose} disabled={isAdding}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddSelected}
|
||||||
|
disabled={selectedIds.size === 0 || isAdding}
|
||||||
|
>
|
||||||
|
{isAdding ? "Adding..." : `Add Selected (${selectedIds.size})`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Image as ImageIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -18,6 +19,7 @@ import { useLocation } from "wouter";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
|
import { setChatbotPendingFiles } from "@/lib/chatbotFileStore";
|
||||||
|
import { ServerAttachmentsPickerModal } from "@/components/file-upload/server-attachments-picker-modal";
|
||||||
|
|
||||||
type Step =
|
type Step =
|
||||||
| "menu"
|
| "menu"
|
||||||
@@ -204,6 +206,7 @@ export function ChatbotButton() {
|
|||||||
renderingProvider: string | null;
|
renderingProvider: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||||
|
const [isServerPickerOpen, setIsServerPickerOpen] = useState(false);
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -1490,6 +1493,16 @@ export function ChatbotButton() {
|
|||||||
>
|
>
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 p-0 shrink-0 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Attach screenshot"
|
||||||
|
onClick={() => setIsServerPickerOpen(true)}
|
||||||
|
disabled={step === "ai-loading"}
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9 w-9 p-0 shrink-0"
|
className="h-9 w-9 p-0 shrink-0"
|
||||||
@@ -1518,6 +1531,13 @@ export function ChatbotButton() {
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ServerAttachmentsPickerModal
|
||||||
|
open={isServerPickerOpen}
|
||||||
|
onClose={() => setIsServerPickerOpen(false)}
|
||||||
|
onFilesSelected={(files) =>
|
||||||
|
setPendingFiles((prev) => [...prev, ...files].slice(0, 5))
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user