@larkup/workspace-app 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import type { SourceDocument, DataGroup } from '@larkup/core/types';
2
2
  import { type VideoRuntimeScope } from '../../lib/media/video-runtime-scope';
3
- export declare function CorpusPanel({ documents, groups, onChanged, isIndexing, activeVideoRuntimeScope, initialGroupId, isLoading, }: {
3
+ export declare function CorpusPanel({ documents, groups, onChanged, isIndexing, activeVideoRuntimeScope, initialGroupId, isLoading, error, }: {
4
4
  documents: SourceDocument[];
5
5
  groups?: DataGroup[];
6
6
  onChanged: () => void | Promise<unknown>;
@@ -8,5 +8,6 @@ export declare function CorpusPanel({ documents, groups, onChanged, isIndexing,
8
8
  activeVideoRuntimeScope: VideoRuntimeScope;
9
9
  initialGroupId?: string;
10
10
  isLoading?: boolean;
11
+ error?: string;
11
12
  }): import("react").JSX.Element;
12
13
  //# sourceMappingURL=corpus-panel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"corpus-panel.d.ts","sourceRoot":"","sources":["../../../src/components/data/corpus-panel.tsx"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAkB,cAAc,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AA+BpF,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,qCAAqC,CAAC;AAU7C,wBAAgB,WAAW,CAAC,EAC1B,SAAS,EACT,MAAW,EACX,SAAS,EACT,UAAU,EACV,uBAAuB,EACvB,cAAc,EACd,SAAS,GACV,EAAE;IACD,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,uBAAuB,EAAE,iBAAiB,CAAC;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,+BAwsBA"}
1
+ {"version":3,"file":"corpus-panel.d.ts","sourceRoot":"","sources":["../../../src/components/data/corpus-panel.tsx"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAkB,cAAc,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AA+BpF,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,qCAAqC,CAAC;AAU7C,wBAAgB,WAAW,CAAC,EAC1B,SAAS,EACT,MAAW,EACX,SAAS,EACT,UAAU,EACV,uBAAuB,EACvB,cAAc,EACd,SAAS,EACT,KAAK,GACN,EAAE;IACD,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,SAAS,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,uBAAuB,EAAE,iBAAiB,CAAC;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,+BAovBA"}
@@ -1 +1 @@
1
- {"version":3,"file":"data-workspace.d.ts","sourceRoot":"","sources":["../../../src/components/data/data-workspace.tsx"],"names":[],"mappings":"AAmEA,KAAK,QAAQ,GAAG,KAAK,GAAG,QAAQ,CAAC;AA+BjC,wBAAgB,aAAa,CAAC,EAAE,IAAI,EAAE,GAAE;IAAE,IAAI,CAAC,EAAE,QAAQ,CAAA;CAAO,+BA4gB/D"}
1
+ {"version":3,"file":"data-workspace.d.ts","sourceRoot":"","sources":["../../../src/components/data/data-workspace.tsx"],"names":[],"mappings":"AAmEA,KAAK,QAAQ,GAAG,KAAK,GAAG,QAAQ,CAAC;AA+BjC,wBAAgB,aAAa,CAAC,EAAE,IAAI,EAAE,GAAE;IAAE,IAAI,CAAC,EAAE,QAAQ,CAAA;CAAO,+BA0hB/D"}
@@ -1 +1 @@
1
- {"version":3,"file":"project-data-page.d.ts","sourceRoot":"","sources":["../../../src/components/data/project-data-page.tsx"],"names":[],"mappings":"AAcA,kEAAkE;AAClE,wBAAgB,eAAe,gCAuC9B"}
1
+ {"version":3,"file":"project-data-page.d.ts","sourceRoot":"","sources":["../../../src/components/data/project-data-page.tsx"],"names":[],"mappings":"AAeA,kEAAkE;AAClE,wBAAgB,eAAe,gCA4C9B"}
@@ -1 +1 @@
1
- {"version":3,"file":"upload-panel.d.ts","sourceRoot":"","sources":["../../../src/components/data/upload-panel.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AA2E/D,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,cAAc,EACd,OAAO,GACR,EAAE;IACD,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,KAAK,IAAI,CAAC;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,+BA+qCA"}
1
+ {"version":3,"file":"upload-panel.d.ts","sourceRoot":"","sources":["../../../src/components/data/upload-panel.tsx"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAiF/D,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EACP,cAAc,EACd,OAAO,GACR,EAAE;IACD,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,KAAK,IAAI,CAAC;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,+BAuuCA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larkup/workspace-app",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Canonical Larkup chat, data and settings pages with host adapters",
6
6
  "files": [
@@ -534,7 +534,7 @@
534
534
  "dependencies": {
535
535
  "@ai-sdk/react": "4.0.136",
536
536
  "@base-ui/react": "^1.5.0",
537
- "@larkup/core": "^0.8.1",
537
+ "@larkup/core": "^0.8.2",
538
538
  "ai": "7.0.133",
539
539
  "class-variance-authority": "^0.7.1",
540
540
  "clsx": "^2.1.1",
@@ -556,9 +556,9 @@
556
556
  "@larkup/vector-stores": "^0.1.27",
557
557
  "@larkup/sandbox": "^0.1.8",
558
558
  "next": "16.3.8",
559
- "@larkup/integrations": "0.2.1",
560
- "@larkup/tool-doc-editor": "0.3.1",
561
- "@larkup/workspace-ui": "0.2.0"
559
+ "@larkup/tool-doc-editor": "0.3.2",
560
+ "@larkup/workspace-ui": "0.2.0",
561
+ "@larkup/integrations": "0.2.1"
562
562
  },
563
563
  "peerDependencies": {
564
564
  "react": ">=19",
@@ -88,6 +88,7 @@ export function CorpusPanel({
88
88
  activeVideoRuntimeScope,
89
89
  initialGroupId,
90
90
  isLoading,
91
+ error,
91
92
  }: {
92
93
  documents: SourceDocument[];
93
94
  groups?: DataGroup[];
@@ -96,12 +97,15 @@ export function CorpusPanel({
96
97
  activeVideoRuntimeScope: VideoRuntimeScope;
97
98
  initialGroupId?: string;
98
99
  isLoading?: boolean;
100
+ error?: string;
99
101
  }) {
100
102
  const [active, setActive] = useState<SourceDocument | null>(null);
101
103
  const [deleteTask, setDeleteTask] = useState<
102
104
  { type: 'single'; doc: SourceDocument } | { type: 'all' } | { type: 'selected' } | null
103
105
  >(null);
104
106
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
107
+ const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
108
+ const [clearingAll, setClearingAll] = useState(false);
105
109
  const [movingGroupId, setMovingGroupId] = useState<string | null>(null);
106
110
  const [inspectorDoc, setInspectorDoc] = useState<SourceDocument | null>(null);
107
111
 
@@ -143,9 +147,18 @@ export function CorpusPanel({
143
147
  others.push(doc);
144
148
  }
145
149
  }
146
- return [...Array.from(grouped.values()), ...others].sort(
147
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
148
- );
150
+ return [...Array.from(grouped.values()), ...others]
151
+ .filter((document) => !deletedIds.has(document.id))
152
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
153
+ }, [deletedIds, documents]);
154
+
155
+ useEffect(() => {
156
+ setDeletedIds((current) => {
157
+ const documentIds = new Set(documents.map((document) => document.id));
158
+ const pending = new Set(Array.from(current).filter((id) => documentIds.has(id)));
159
+ return pending.size === current.size ? current : pending;
160
+ });
161
+ if (documents.length === 0) setClearingAll(false);
149
162
  }, [documents]);
150
163
 
151
164
  const filteredDocuments = groupedDocuments.filter(
@@ -182,6 +195,8 @@ export function CorpusPanel({
182
195
  }
183
196
 
184
197
  async function del(id: string, doc?: SourceDocument) {
198
+ setDeleteTask(null);
199
+ setDeletedIds((current) => new Set(current).add(id));
185
200
  try {
186
201
  if (doc?.metadata?.isGroup && doc?.metadata?.mediaAssetId) {
187
202
  await deleteRequest(workspaceUrl(`/api/media?id=${doc.metadata.mediaAssetId}`));
@@ -196,12 +211,20 @@ export function CorpusPanel({
196
211
  await onChanged();
197
212
  toast.success('Document deleted');
198
213
  } catch (error) {
214
+ setDeletedIds((current) => {
215
+ const next = new Set(current);
216
+ next.delete(id);
217
+ return next;
218
+ });
199
219
  toast.error(formatErrorMessage(error) || 'Could not delete this document.');
200
220
  }
201
221
  }
202
222
  async function delSelected() {
203
223
  if (selectedIds.size === 0) return;
204
224
  const toDelete = groupedDocuments.filter((d) => selectedIds.has(d.id));
225
+ const deletionIds = new Set(toDelete.map((document) => document.id));
226
+ setDeleteTask(null);
227
+ setDeletedIds((current) => new Set([...current, ...deletionIds]));
205
228
  try {
206
229
  for (const doc of toDelete) {
207
230
  if (doc.metadata?.isGroup && doc.metadata?.mediaAssetId) {
@@ -214,16 +237,24 @@ export function CorpusPanel({
214
237
  await onChanged();
215
238
  toast.success(`Deleted ${toDelete.length} item(s)`);
216
239
  } catch (error) {
240
+ setDeletedIds((current) => {
241
+ const next = new Set(current);
242
+ for (const id of deletionIds) next.delete(id);
243
+ return next;
244
+ });
217
245
  toast.error(formatErrorMessage(error) || 'Could not delete the selected documents.');
218
246
  }
219
247
  }
220
248
  async function clearAll() {
249
+ setDeleteTask(null);
250
+ setClearingAll(true);
221
251
  try {
222
252
  await deleteRequest(workspaceUrl('/api/documents'));
223
253
  setSelectedIds(new Set());
224
254
  await onChanged();
225
255
  toast.success('Corpus and vector index cleared');
226
256
  } catch (error) {
257
+ setClearingAll(false);
227
258
  toast.error(formatErrorMessage(error) || 'Could not clear the corpus.');
228
259
  }
229
260
  }
@@ -299,7 +330,22 @@ export function CorpusPanel({
299
330
  return <CorpusSkeleton />;
300
331
  }
301
332
 
302
- if (documents.length === 0) {
333
+ if (error) {
334
+ return (
335
+ <div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card/50 px-6 py-14 text-center">
336
+ <div className="size-10 p-1.5 bg-destructive/10 border border-destructive/20 rounded-md flex items-center justify-center text-destructive">
337
+ <Info className="size-6" />
338
+ </div>
339
+ <p className="mt-3 text-lg font-medium text-foreground">Could not load corpus data</p>
340
+ <p className="mt-1 max-w-xl text-sm text-muted-foreground">{error}</p>
341
+ <Button variant="outline" size="sm" className="mt-4" onClick={() => void onChanged()}>
342
+ Retry
343
+ </Button>
344
+ </div>
345
+ );
346
+ }
347
+
348
+ if (clearingAll || groupedDocuments.length === 0) {
303
349
  return (
304
350
  <div className="flex flex-col items-center justify-center rounded-lg px-6 py-14 text-center">
305
351
  <div className="size-10 p-1.5 bg-white/60 border border-border rounded-md flex items-center justify-center">
@@ -1144,7 +1190,11 @@ function DocumentDialog({
1144
1190
 
1145
1191
  function CorpusSkeleton() {
1146
1192
  return (
1147
- <div className="space-y-4 animate-in fade-in duration-150">
1193
+ <div
1194
+ role="status"
1195
+ aria-label="Loading corpus"
1196
+ className="space-y-4 animate-in fade-in duration-150"
1197
+ >
1148
1198
  {/* Filter bar skeleton */}
1149
1199
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center justify-between pb-3">
1150
1200
  <div className="flex flex-wrap items-center gap-2">
@@ -190,9 +190,13 @@ export function DataWorkspace({ view }: { view?: TopTabId } = {}) {
190
190
  const completedJobs = jobs.filter((job) => job.status === 'completed');
191
191
  const [jobsFilterTab, setJobsFilterTab] = useState<'running' | 'all'>('running');
192
192
 
193
- const docsQuery = useSWR<DocsResponse>(workspaceUrl('/api/documents'), fetcher, {
194
- refreshInterval: hasActive ? 5000 : 0,
195
- });
193
+ const docsQuery = useSWR<DocsResponse>(
194
+ serverId ? workspaceUrl('/api/documents') : null,
195
+ fetcher,
196
+ {
197
+ refreshInterval: hasActive ? 5000 : 0,
198
+ },
199
+ );
196
200
  const { mutate: mutateDocuments } = docsQuery;
197
201
  const documents = docsQuery.data?.documents ?? [];
198
202
  const groups = docsQuery.data?.groups ?? [];
@@ -211,6 +215,9 @@ export function DataWorkspace({ view }: { view?: TopTabId } = {}) {
211
215
  docsQuery.data !== undefined &&
212
216
  !docsQuery.error &&
213
217
  (targetGroupId === 'default' || selectedGroup !== undefined);
218
+ const isCorpusLoading = Boolean(
219
+ !serverId || docsQuery.isLoading || (docsQuery.data === undefined && !docsQuery.error),
220
+ );
214
221
 
215
222
  useEffect(() => {
216
223
  if (dataAddBlocked) setPrimaryAction(null);
@@ -613,7 +620,14 @@ export function DataWorkspace({ view }: { view?: TopTabId } = {}) {
613
620
  isIndexing={indexRunning}
614
621
  activeVideoRuntimeScope={activeVideoRuntimeScope}
615
622
  initialGroupId={searchParams.has('groupId') ? queryGroupId : undefined}
616
- isLoading={docsQuery.isLoading || (docsQuery.data === undefined && !docsQuery.error)}
623
+ isLoading={isCorpusLoading}
624
+ error={
625
+ docsQuery.error
626
+ ? docsQuery.error instanceof Error
627
+ ? docsQuery.error.message
628
+ : String(docsQuery.error)
629
+ : undefined
630
+ }
617
631
  />
618
632
  </div>
619
633
  )}
@@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
7
7
  import { Database, FolderOpen } from 'lucide-react';
8
8
  import { WorkspaceLineTabs, WorkspacePageHeading } from '@larkup/workspace-ui';
9
9
  import type { DataGroup } from '@larkup/core/types';
10
+ import { useProject } from '../projects/project-provider';
10
11
  import { DataWorkspace } from './data-workspace';
11
12
  import { GroupsPanel } from './groups-panel';
12
13
 
@@ -15,8 +16,13 @@ const fetcher = (url: string) => fetch(url).then((response) => response.json());
15
16
  /** Simple, tab-first source management for the active Project. */
16
17
  export function ProjectDataPage() {
17
18
  const router = useRouter();
19
+ const { activeProject } = useProject();
20
+ const serverId = activeProject?.id;
18
21
  const [activeView, setActiveView] = useState<'all' | 'groups'>('all');
19
- const { data } = useSWR<{ groups: DataGroup[] }>(workspaceUrl('/api/groups'), fetcher);
22
+ const { data } = useSWR<{ groups: DataGroup[] }>(
23
+ serverId ? workspaceUrl('/api/groups') : null,
24
+ fetcher,
25
+ );
20
26
  const groups = data?.groups ?? [];
21
27
 
22
28
  return (
@@ -83,6 +83,12 @@ interface ImageIndexingCapability {
83
83
  message?: string;
84
84
  }
85
85
 
86
+ interface PreparingFile {
87
+ id: string;
88
+ name: string;
89
+ size: number;
90
+ }
91
+
86
92
  const stagedFilesByScope = new Map<string, StagedFile[]>();
87
93
  const UPLOAD_QUEUE_KEY = 'larkup.upload-queue.v1';
88
94
 
@@ -179,6 +185,7 @@ export function UploadPanel({
179
185
  const [remoteUrl, setRemoteUrl] = useState('');
180
186
  const [loadingRemoteFile, setLoadingRemoteFile] = useState(false);
181
187
  const [saving, setSaving] = useState(false);
188
+ const [preparingFiles, setPreparingFiles] = useState<PreparingFile[]>([]);
182
189
  const [progress, setProgress] = useState<{
183
190
  message?: string;
184
191
  current: number;
@@ -196,7 +203,17 @@ export function UploadPanel({
196
203
  const joinedActiveSave = savingRef.current;
197
204
  const next: StagedFile[] = [];
198
205
  const stage = (file: StagedFile) => next.push({ ...file, sourceUrl });
199
- for (const file of Array.from(files)) {
206
+ const selectedFiles = Array.from(files).map((file) => ({
207
+ file,
208
+ pending: {
209
+ id: Math.random().toString(36).slice(2),
210
+ name: file.name,
211
+ size: file.size,
212
+ },
213
+ }));
214
+ setPreparingFiles((current) => [...current, ...selectedFiles.map(({ pending }) => pending)]);
215
+
216
+ for (const { file, pending } of selectedFiles) {
200
217
  const ext = file.name.split('.').pop()?.toLowerCase();
201
218
 
202
219
  // Prevent dragging media into the documents tab
@@ -205,10 +222,11 @@ export function UploadPanel({
205
222
  toast.error(
206
223
  `Unsupported file type: ${file.name}. Please upload media files in the Media tab.`,
207
224
  );
225
+ setPreparingFiles((current) => current.filter((item) => item.id !== pending.id));
208
226
  continue;
209
227
  }
210
228
 
211
- const id = Math.random().toString(36).slice(2);
229
+ const id = pending.id;
212
230
  try {
213
231
  if (ext === 'csv') {
214
232
  const content = await file.text();
@@ -320,12 +338,18 @@ export function UploadPanel({
320
338
  });
321
339
 
322
340
  const data = await res.json().catch(() => ({}));
323
- if (!res.ok) {
341
+ const legacyImageOnlyPdf =
342
+ ext === 'pdf' &&
343
+ res.status === 422 &&
344
+ /(?:no readable text|does not contain selectable text)/i.test(String(data.error ?? ''));
345
+ if (!res.ok && !legacyImageOnlyPdf) {
324
346
  throw new Error(data.error || `Could not extract text from ${file.name}`);
325
347
  }
326
348
 
327
349
  const { text } = data;
328
- const hasReadableText = data.hasReadableText ?? Boolean(text?.trim());
350
+ const hasReadableText = legacyImageOnlyPdf
351
+ ? false
352
+ : (data.hasReadableText ?? Boolean(text?.trim()));
329
353
  const imageOnlyPdf = ext === 'pdf' && !hasReadableText;
330
354
  if (!hasReadableText && !imageOnlyPdf) {
331
355
  throw new Error(
@@ -363,6 +387,8 @@ export function UploadPanel({
363
387
  }
364
388
  } catch (err) {
365
389
  toast.error(`Could not read ${file.name}. ${formatErrorMessage(err)}`);
390
+ } finally {
391
+ setPreparingFiles((current) => current.filter((item) => item.id !== pending.id));
366
392
  }
367
393
  }
368
394
  setStaged((prev) => [...prev, ...next]);
@@ -687,7 +713,7 @@ export function UploadPanel({
687
713
  }
688
714
 
689
715
  useEffect(() => {
690
- const actionBusy = saving || loadingRemoteFile;
716
+ const actionBusy = saving || loadingRemoteFile || preparingFiles.length > 0;
691
717
  onActionChange?.({
692
718
  label: 'Add file',
693
719
  onClick: isRemoteEntry ? () => void loadRemoteFile() : () => void ingest(),
@@ -695,7 +721,16 @@ export function UploadPanel({
695
721
  loading: actionBusy,
696
722
  });
697
723
  return () => onActionChange?.(null);
698
- }, [entryTab, staged, saving, remoteUrl, loadingRemoteFile, groupId, onActionChange]);
724
+ }, [
725
+ entryTab,
726
+ staged,
727
+ saving,
728
+ remoteUrl,
729
+ loadingRemoteFile,
730
+ preparingFiles.length,
731
+ groupId,
732
+ onActionChange,
733
+ ]);
699
734
 
700
735
  function updateEditingFile(patch: Partial<StagedFile>) {
701
736
  setStaged((prev) => prev.map((f) => (f.id === editingFileId ? { ...f, ...patch } : f)));
@@ -775,6 +810,30 @@ export function UploadPanel({
775
810
  }}
776
811
  />
777
812
 
813
+ {preparingFiles.length > 0 && (
814
+ <div
815
+ role="status"
816
+ aria-live="polite"
817
+ className="space-y-2 rounded-md border border-border bg-card px-3 py-3"
818
+ >
819
+ <div className="flex items-center gap-2 text-sm font-medium">
820
+ <Loader2 className="size-4 animate-spin text-primary" />
821
+ Preparing {preparingFiles.length} file{preparingFiles.length === 1 ? '' : 's'}…
822
+ </div>
823
+ <ul className="space-y-1">
824
+ {preparingFiles.map((file) => (
825
+ <li
826
+ key={file.id}
827
+ className="flex items-center justify-between gap-3 text-xs text-muted-foreground"
828
+ >
829
+ <span className="truncate font-mono">{file.name}</span>
830
+ <span className="shrink-0 tabular-nums">{(file.size / 1024).toFixed(1)} KB</span>
831
+ </li>
832
+ ))}
833
+ </ul>
834
+ </div>
835
+ )}
836
+
778
837
  {staged.length > 0 && (
779
838
  <div className="space-y-2 pb-5">
780
839
  {staged.some((file) => file.name.toLowerCase().endsWith('.pdf')) &&
@@ -987,10 +1046,13 @@ export function UploadPanel({
987
1046
  <Button
988
1047
  onClick={isRemoteEntry ? () => void loadRemoteFile() : () => void ingest()}
989
1048
  disabled={
990
- loadingRemoteFile || saving || (isRemoteEntry ? !remoteUrl.trim() : staged.length === 0)
1049
+ loadingRemoteFile ||
1050
+ saving ||
1051
+ preparingFiles.length > 0 ||
1052
+ (isRemoteEntry ? !remoteUrl.trim() : staged.length === 0)
991
1053
  }
992
1054
  >
993
- {loadingRemoteFile || saving ? (
1055
+ {loadingRemoteFile || saving || preparingFiles.length > 0 ? (
994
1056
  <Loader2 className="size-4 animate-spin" />
995
1057
  ) : (
996
1058
  <FileUp className="size-4" />