Skip to content
166 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The top of every page is one surface: the breadcrumb, search, spend, Ask g1t, bell and + share one height, corners and spacing, Ask g1t is a plain tinted button with the g1t mark drawn on whole pixels, a page's own header sits directly under the bar at the same left edge, and every section, conversations and open artifacts included, draws the same rounded panel. Tables written in chat render as tables, and the site's file, checkbox, radio, number and date controls are the design system's. Button, Separator, Breadcrumb, Table and FileDrop join the shared components; the chat guide says how tables work.1import { FileText, Upload, X } from "lucide-react";
2import { type ChangeEvent, type DragEvent, type ReactNode, useEffect, useId, useRef, useState } from "react";
3
4import { cn } from "../../lib/cn";
5
6// A file field as a dropzone. The real <input type="file"> is inside it,
7// visually hidden, so its name, accept, multiple and required work as they
8// always do and the form posts the files exactly as a bare input would. A
9// click or Enter on the zone opens the picker; files dropped on it are put
10// on the input and a change event is sent, as if they had been picked.
11
12type Chosen = { name: string; size: number };
13
14/** Bytes as a person reads them: 812 B, 14.2 KB, 1.3 MB. */
15function fileSize(bytes: number): string {
16 if (bytes < 1024) return `${bytes} B`;
17 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
18 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
19}
20
21export function FileDrop({
22 id,
23 name,
24 accept,
25 multiple = false,
26 required,
27 hint,
28 label,
29 compact = false,
30 className,
31 onChange,
32 "aria-describedby": describedBy,
33}: {
34 id?: string;
35 name?: string;
36 accept?: string;
37 multiple?: boolean;
38 required?: boolean;
39 /** A line under the prompt, such as the types and the size allowed. */
40 hint?: ReactNode;
41 /** The field's name for a screen reader, when nothing else labels it. */
42 label?: string;
43 /** One row, for a tight spot beside other controls. */
44 compact?: boolean;
45 className?: string;
46 onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
47 "aria-describedby"?: string;
48}) {
49 const generated = useId();
50 const inputId = id ?? generated;
51 const hintId = `${inputId}-hint`;
52 const input = useRef<HTMLInputElement>(null);
53 const [files, setFiles] = useState<Chosen[]>([]);
54 const [dragging, setDragging] = useState(false);
55
56 // A form reset empties the input; the list follows it.
57 useEffect(() => {
58 const form = input.current?.form;
59 if (!form) return;
60 const onReset = () => setFiles([]);
61 form.addEventListener("reset", onReset);
62 return () => form.removeEventListener("reset", onReset);
63 }, []);
64
65 const changed = (event: ChangeEvent<HTMLInputElement>) => {
66 setFiles(Array.from(event.currentTarget.files ?? [], (file) => ({ name: file.name, size: file.size })));
67 onChange?.(event);
68 };
69
70 const drop = (event: DragEvent<HTMLLabelElement>) => {
71 event.preventDefault();
72 setDragging(false);
73 const field = input.current;
74 const dropped = event.dataTransfer.files;
75 if (!field || dropped.length === 0) return;
76 if (multiple || dropped.length === 1) {
77 field.files = dropped;
78 } else {
79 const one = new DataTransfer();
80 one.items.add(dropped[0]);
81 field.files = one.files;
82 }
83 field.dispatchEvent(new Event("change", { bubbles: true }));
84 };
85
86 const clear = () => {
87 const field = input.current;
88 if (!field) return;
89 field.value = "";
90 field.dispatchEvent(new Event("change", { bubbles: true }));
91 field.focus();
92 };
93
94 const describedByIds = [describedBy, hint ? hintId : null].filter(Boolean).join(" ") || undefined;
95 const plural = multiple;
96
97 return (
98 <div className={cn("min-w-0", className)}>
99 <label
100 htmlFor={inputId}
101 onDragEnter={(event) => {
102 event.preventDefault();
103 setDragging(true);
104 }}
105 onDragOver={(event) => {
106 event.preventDefault();
107 event.dataTransfer.dropEffect = "copy";
108 }}
109 onDragLeave={(event) => {
110 if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
111 }}
112 onDrop={drop}
113 className={cn(
114 "relative flex cursor-pointer rounded-lg border border-dashed border-line-strong bg-bg text-sm text-muted transition-colors",
115 "hover:border-accent/50 hover:bg-surface has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent has-[:focus-visible]:ring-offset-1 has-[:focus-visible]:ring-offset-bg",
116 compact ? "min-h-11 items-center gap-2 px-3 py-1.5 sm:min-h-9" : "flex-col items-center justify-center gap-1.5 px-4 py-6 text-center",
117 dragging && "border-accent bg-accent/5 hover:border-accent hover:bg-accent/5",
118 )}
119 >
120 <input
121 ref={input}
122 id={inputId}
123 type="file"
124 name={name}
125 accept={accept}
126 multiple={multiple}
127 required={required}
128 aria-describedby={describedByIds}
129 onChange={changed}
130 className="sr-only"
131 />
132 {label && <span className="sr-only">{label}</span>}
133 <Upload size={compact ? 14 : 18} aria-hidden className={cn("shrink-0", dragging ? "text-accent" : "text-faint")} />
134 <span className="min-w-0">
135 <span className="font-medium text-accent">{plural ? "Choose files" : "Choose a file"}</span> or drop {plural ? "them" : "it"} here
136 </span>
137 {hint && (
138 <span id={hintId} className={cn("text-xs text-faint", compact && "sr-only")}>
139 {hint}
140 </span>
141 )}
142 </label>
143 {files.length > 0 && (
144 <div className="mt-2 flex items-start gap-2 rounded-lg border border-line bg-surface py-1 pr-1 pl-3">
145 <ul aria-label="Chosen files" className="min-w-0 grow">
146 {files.map((file, index) => (
147 <li key={`${file.name}-${index}`} className="flex min-h-8 items-center gap-2 text-sm">
148 <FileText size={14} aria-hidden className="shrink-0 text-faint" />
149 <span className="min-w-0 grow truncate">{file.name}</span>
150 <span className="shrink-0 text-xs text-faint tabular-nums">{fileSize(file.size)}</span>
151 </li>
152 ))}
153 </ul>
154 <button
155 type="button"
156 onClick={clear}
157 className="inline-flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-xs text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent"
158 >
159 <X size={13} aria-hidden />
160 Clear
161 </button>
162 </div>
163 )}
164 </div>
165 );
166}