@larkup/workspace-app 0.2.0 → 0.2.2

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 +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,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,+BAquBA"}
@@ -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.0",
3
+ "version": "0.2.2",
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.0",
537
+ "@larkup/core": "^0.8.1",
538
538
  "ai": "7.0.133",
539
539
  "class-variance-authority": "^0.7.1",
540
540
  "clsx": "^2.1.1",
@@ -557,7 +557,7 @@
557
557
  "@larkup/sandbox": "^0.1.8",
558
558
  "next": "16.3.8",
559
559
  "@larkup/integrations": "0.2.1",
560
- "@larkup/tool-doc-editor": "0.3.0",
560
+ "@larkup/tool-doc-editor": "0.3.1",
561
561
  "@larkup/workspace-ui": "0.2.0"
562
562
  },
563
563
  "peerDependencies": {
@@ -936,14 +936,6 @@ function ChatWorkspaceInner({ chatId }: { chatId?: string }) {
936
936
  const ready = status?.ready ?? false;
937
937
  const selectedModelData = status?.availableModels?.find((m) => m.id === selectedModel);
938
938
 
939
- if (statusLoading) {
940
- return (
941
- <div className="flex flex-1 items-center justify-center">
942
- <Loader2 className="size-6 animate-spin text-muted-foreground" />
943
- </div>
944
- );
945
- }
946
-
947
939
  return (
948
940
  <div
949
941
  className={cn(
@@ -1284,18 +1276,28 @@ function ChatWorkspaceInner({ chatId }: { chatId?: string }) {
1284
1276
  {!ready ? (
1285
1277
  <div className="flex flex-col items-center justify-center gap-3 pt-[18vh] text-center">
1286
1278
  <div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white border text-muted-foreground">
1287
- <MessageCircle className="size-7" />
1279
+ {statusLoading ? (
1280
+ <Loader2 className="size-6 animate-spin" />
1281
+ ) : (
1282
+ <MessageCircle className="size-7" />
1283
+ )}
1288
1284
  </div>
1289
1285
  <h1 className="text-xl font-semibold tracking-tight text-foreground">
1290
- {statusError ? 'Chat temporarily unavailable' : 'Setup Required'}
1286
+ {statusLoading
1287
+ ? 'Preparing your workspace'
1288
+ : statusError
1289
+ ? 'Chat temporarily unavailable'
1290
+ : 'Setup Required'}
1291
1291
  </h1>
1292
1292
  <p className="max-w-md text-sm leading-relaxed text-muted-foreground mb-2">
1293
- {statusError
1294
- ? 'Chat services are temporarily unavailable. Larkup will retry automatically.'
1295
- : status?.blockers?.join(' ') ||
1296
- 'Connect a chat model in Settings to start chatting.'}
1293
+ {statusLoading
1294
+ ? 'Loading your chat configuration and available models…'
1295
+ : statusError
1296
+ ? 'Chat services are temporarily unavailable. Larkup will retry automatically.'
1297
+ : status?.blockers?.join(' ') ||
1298
+ 'Connect a chat model in Settings to start chatting.'}
1297
1299
  </p>
1298
- {!statusError && (
1300
+ {!statusLoading && !statusError && (
1299
1301
  <a
1300
1302
  href={
1301
1303
  status?.blockers?.some((b) => b.includes('API Key') || b.includes('Settings'))
@@ -102,6 +102,8 @@ export function CorpusPanel({
102
102
  { type: 'single'; doc: SourceDocument } | { type: 'all' } | { type: 'selected' } | null
103
103
  >(null);
104
104
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
105
+ const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set());
106
+ const [clearingAll, setClearingAll] = useState(false);
105
107
  const [movingGroupId, setMovingGroupId] = useState<string | null>(null);
106
108
  const [inspectorDoc, setInspectorDoc] = useState<SourceDocument | null>(null);
107
109
 
@@ -143,9 +145,18 @@ export function CorpusPanel({
143
145
  others.push(doc);
144
146
  }
145
147
  }
146
- return [...Array.from(grouped.values()), ...others].sort(
147
- (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
148
- );
148
+ return [...Array.from(grouped.values()), ...others]
149
+ .filter((document) => !deletedIds.has(document.id))
150
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
151
+ }, [deletedIds, documents]);
152
+
153
+ useEffect(() => {
154
+ setDeletedIds((current) => {
155
+ const documentIds = new Set(documents.map((document) => document.id));
156
+ const pending = new Set(Array.from(current).filter((id) => documentIds.has(id)));
157
+ return pending.size === current.size ? current : pending;
158
+ });
159
+ if (documents.length === 0) setClearingAll(false);
149
160
  }, [documents]);
150
161
 
151
162
  const filteredDocuments = groupedDocuments.filter(
@@ -182,6 +193,8 @@ export function CorpusPanel({
182
193
  }
183
194
 
184
195
  async function del(id: string, doc?: SourceDocument) {
196
+ setDeleteTask(null);
197
+ setDeletedIds((current) => new Set(current).add(id));
185
198
  try {
186
199
  if (doc?.metadata?.isGroup && doc?.metadata?.mediaAssetId) {
187
200
  await deleteRequest(workspaceUrl(`/api/media?id=${doc.metadata.mediaAssetId}`));
@@ -196,12 +209,20 @@ export function CorpusPanel({
196
209
  await onChanged();
197
210
  toast.success('Document deleted');
198
211
  } catch (error) {
212
+ setDeletedIds((current) => {
213
+ const next = new Set(current);
214
+ next.delete(id);
215
+ return next;
216
+ });
199
217
  toast.error(formatErrorMessage(error) || 'Could not delete this document.');
200
218
  }
201
219
  }
202
220
  async function delSelected() {
203
221
  if (selectedIds.size === 0) return;
204
222
  const toDelete = groupedDocuments.filter((d) => selectedIds.has(d.id));
223
+ const deletionIds = new Set(toDelete.map((document) => document.id));
224
+ setDeleteTask(null);
225
+ setDeletedIds((current) => new Set([...current, ...deletionIds]));
205
226
  try {
206
227
  for (const doc of toDelete) {
207
228
  if (doc.metadata?.isGroup && doc.metadata?.mediaAssetId) {
@@ -214,16 +235,24 @@ export function CorpusPanel({
214
235
  await onChanged();
215
236
  toast.success(`Deleted ${toDelete.length} item(s)`);
216
237
  } catch (error) {
238
+ setDeletedIds((current) => {
239
+ const next = new Set(current);
240
+ for (const id of deletionIds) next.delete(id);
241
+ return next;
242
+ });
217
243
  toast.error(formatErrorMessage(error) || 'Could not delete the selected documents.');
218
244
  }
219
245
  }
220
246
  async function clearAll() {
247
+ setDeleteTask(null);
248
+ setClearingAll(true);
221
249
  try {
222
250
  await deleteRequest(workspaceUrl('/api/documents'));
223
251
  setSelectedIds(new Set());
224
252
  await onChanged();
225
253
  toast.success('Corpus and vector index cleared');
226
254
  } catch (error) {
255
+ setClearingAll(false);
227
256
  toast.error(formatErrorMessage(error) || 'Could not clear the corpus.');
228
257
  }
229
258
  }
@@ -299,7 +328,7 @@ export function CorpusPanel({
299
328
  return <CorpusSkeleton />;
300
329
  }
301
330
 
302
- if (documents.length === 0) {
331
+ if (clearingAll || groupedDocuments.length === 0) {
303
332
  return (
304
333
  <div className="flex flex-col items-center justify-center rounded-lg px-6 py-14 text-center">
305
334
  <div className="size-10 p-1.5 bg-white/60 border border-border rounded-md flex items-center justify-center">
@@ -1144,7 +1173,11 @@ function DocumentDialog({
1144
1173
 
1145
1174
  function CorpusSkeleton() {
1146
1175
  return (
1147
- <div className="space-y-4 animate-in fade-in duration-150">
1176
+ <div
1177
+ role="status"
1178
+ aria-label="Loading corpus"
1179
+ className="space-y-4 animate-in fade-in duration-150"
1180
+ >
1148
1181
  {/* Filter bar skeleton */}
1149
1182
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center justify-between pb-3">
1150
1183
  <div className="flex flex-wrap items-center gap-2">
@@ -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" />