@sinequa/atomic-angular 1.15.1 → 1.17.0

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.
@@ -7,7 +7,7 @@ import { Upload } from 'tus-js-client';
7
7
  import { toSignal } from '@angular/core/rxjs-interop';
8
8
  import { TranslocoService, TranslocoPipe, provideTranslocoScope } from '@jsverse/transloco';
9
9
  import { Temporal } from 'temporal-polyfill';
10
- import { DialogComponent, ButtonComponent, DialogContentComponent, DialogFooterComponent, DialogTitleComponent, FolderIcon, UserIcon, UserRoundIcon, FileIcon, TooltipDirective, TrashIcon, DialogService, ListIcon, MagnifyingGlassIcon, PlusIcon, XMarkIcon, FileSpreadsheetIcon, FileTextIcon, UploadIcon, DialogHeaderComponent, ChatPlusIcon, ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, EyeIcon, KeyIcon, MenuComponent, MenuContentComponent, MenuItemComponent, PenToSquareIcon, RefreshIcon, TagIcon, CheckIcon, CircleXIcon, SyncIcon } from '@sinequa/ui';
10
+ import { DialogComponent, ButtonComponent, DialogContentComponent, DialogFooterComponent, DialogTitleComponent, FolderIcon, UserIcon, UserRoundIcon, FileIcon, TooltipDirective, TrashIcon, DialogService, ListIcon, MagnifyingGlassIcon, PlusIcon, XMarkIcon, FileSpreadsheetIcon, FileTextIcon, UploadIcon, FileSearchIcon, DialogHeaderComponent, ChatPlusIcon, ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon, MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon, EyeIcon, KeyIcon, MenuComponent, MenuContentComponent, MenuItemComponent, PenToSquareIcon, RefreshIcon, TagIcon, CheckIcon, CircleXIcon, SyncIcon } from '@sinequa/ui';
11
11
  import { DomSanitizer } from '@angular/platform-browser';
12
12
  import { computePosition, offset, flip, shift, autoUpdate } from '@floating-ui/dom';
13
13
 
@@ -132,8 +132,8 @@ function withDocumentCountAdjusted(workspaces, id, delta) {
132
132
  * touches the cache once the server has answered, so a rejected call leaves
133
133
  * the state as it was — the call site reports the error.
134
134
  *
135
- * `addDocuments` alone stays purely local: it is the upload flow's optimistic
136
- * pre-transfer cache update.
135
+ * `addDocuments`, `discardDocuments` and `patchDocument` stay purely local. The upload
136
+ * flow no longer uses them: the list is the index, and only the listing writes to it.
137
137
  */
138
138
  const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Workspaces"), withState(initialState$1), withComputed(({ workspaces, pagination, selectedIds }) => ({
139
139
  hasMore: computed(() => workspaces().length < pagination().total && workspaces().length < MAX_LOADED_WORKSPACES),
@@ -164,8 +164,8 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
164
164
  };
165
165
  });
166
166
  };
167
- const loadDocuments = async (workspaceId) => {
168
- const res = await listContainerDocuments(workspaceId, { page: 1, pageSize: PAGE_SIZE });
167
+ const loadDocuments = async (workspaceId, signal) => {
168
+ const res = await listContainerDocuments(workspaceId, { page: 1, pageSize: PAGE_SIZE }, { signal });
169
169
  const documents = res.documents.map(mapContainerDocumentToWorkspaceDocument);
170
170
  patchState(store, state => ({
171
171
  documentPagination: { ...state.documentPagination, [workspaceId]: { page: res.page, total: res.total } }
@@ -314,6 +314,23 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
314
314
  }));
315
315
  return { succeeded: result.succeeded, failed: result.failed.map(f => ({ id: f.containerId, error: f.error })) };
316
316
  },
317
+ /**
318
+ * Local only — for when a read already proved a workspace is gone from the
319
+ * server (a 404), rather than this session deleting it: the detail view
320
+ * calls this once the user actually leaves a workspace its background
321
+ * refresh found deleted, instead of {@link remove}, which would call
322
+ * DELETE on something already deleted. Kept in the cache until then, so
323
+ * the view can still show its name while it explains what happened.
324
+ */
325
+ forget(id) {
326
+ patchState(store, state => ({
327
+ workspaces: state.workspaces.filter(w => w.id !== id),
328
+ pagination: state.workspaces.some(w => w.id === id) ? { ...state.pagination, total: Math.max(0, state.pagination.total - 1) } : state.pagination,
329
+ documents: Object.fromEntries(Object.entries(state.documents).filter(([wid]) => wid !== id)),
330
+ documentPagination: Object.fromEntries(Object.entries(state.documentPagination).filter(([wid]) => wid !== id)),
331
+ selectedIds: state.selectedIds.filter(selected => selected !== id)
332
+ }));
333
+ },
317
334
  loadDocuments,
318
335
  /** Without this, a workspace with more than one page of documents is silently truncated. */
319
336
  async loadMoreDocuments(workspaceId) {
@@ -336,17 +353,16 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
336
353
  return merged;
337
354
  },
338
355
  /**
339
- * Local only — the upload flow renders a document optimistically, before
340
- * its bytes are even sent; the server persists it separately once the
341
- * transfer completes.
356
+ * Local only: adds documents to the cache without calling the API. The upload
357
+ * flow does not use it (a file enters the list once the listing returns it).
342
358
  */
343
359
  addDocuments(workspaceId, documents) {
344
360
  setDocuments(workspaceId, [...(store.documents()[workspaceId] ?? []), ...documents]);
345
361
  adjustDocumentCount(workspaceId, documents.length);
346
362
  },
347
363
  /**
348
- * Local only, the counterpart of {@link addDocuments}: forgets documents the
349
- * server never persisted — an upload cancelled before it finished.
364
+ * Local only, the counterpart of {@link addDocuments}: forgets documents without
365
+ * calling the API.
350
366
  * `removeDocuments` is the one that deletes on the server.
351
367
  */
352
368
  discardDocuments(workspaceId, documentIds) {
@@ -355,7 +371,7 @@ const WorkspacesStore = signalStore({ providedIn: "root" }, withDevtools("Worksp
355
371
  setDocuments(workspaceId, after);
356
372
  adjustDocumentCount(workspaceId, after.length - before.length);
357
373
  },
358
- /** Replaces one cached document (status/progress updates coming from the upload flow), matched by id. */
374
+ /** Local only: merges a patch into one cached document, matched by id. */
359
375
  patchDocument(workspaceId, documentId, patch) {
360
376
  patchState(store, state => ({
361
377
  documents: {
@@ -420,7 +436,8 @@ const initialState = {
420
436
  workspaces: [],
421
437
  connection: null,
422
438
  clearedCount: 0,
423
- jobIndexing: {}
439
+ jobIndexing: {},
440
+ ignoredJobs: {}
424
441
  };
425
442
  const FINISHED = ["done", "error", "rejected"];
426
443
  const FAILED = ["error", "rejected"];
@@ -476,6 +493,45 @@ function phaseProgress(uploads, jobIndexing) {
476
493
  function updateOne(uploads, id, change) {
477
494
  return uploads.map(upload => (upload.id === id ? change(upload) : upload));
478
495
  }
496
+ /** `ignored` plus the jobs of `uploads`. */
497
+ function withIgnored(ignored, uploads) {
498
+ const next = { ...ignored };
499
+ for (const upload of uploads)
500
+ next[jobOf(upload)] = true;
501
+ return next;
502
+ }
503
+ /** The row standing for a whole job, whose files the API does not list: `<jobId>/`. */
504
+ const remoteJobRowId = (jobId) => `${jobId}/`;
505
+ /** Where a whole job stands, as one row: what the API says of its two phases. */
506
+ function remoteJobState(job) {
507
+ if (job.uploadStatus === "uploading")
508
+ return { status: "uploading", percent: job.uploadProgress?.percentage ?? 0 };
509
+ if (job.uploadStatus === "upload_interrupted")
510
+ return { status: "error", percent: 0 };
511
+ if (job.indexingStatus === "complete")
512
+ return { status: "done", percent: 100 };
513
+ if (job.indexingStatus === "failed" || job.indexingStatus === "interrupted")
514
+ return { status: "error", percent: 0 };
515
+ // The job's aggregate indexing percentage lives in `jobIndexing`: no file has its own.
516
+ return { status: "indexing", percent: 0 };
517
+ }
518
+ /**
519
+ * The rows a job started elsewhere shows as: one per file the API names (errors and
520
+ * rejections, the only per-file data it has), and one for the job itself — unless a
521
+ * named file already explains why it failed.
522
+ */
523
+ function remoteRows({ job, workspaceId, mine }) {
524
+ const base = { type: "doc", workspaceId, retryable: false, percent: 0, remote: { mine } };
525
+ const files = [
526
+ ...(job.errors ?? []).map(e => ({ ...base, id: `${job.jobId}/${e.fileName}`, name: e.fileName, status: "error", message: e.error ?? undefined })),
527
+ ...(job.rejected ?? []).map(r => ({ ...base, id: `${job.jobId}/${r.fileName}`, name: r.fileName, status: "rejected", message: r.reason }))
528
+ ];
529
+ const state = remoteJobState(job);
530
+ if (state.status === "error" && files.length)
531
+ return files;
532
+ return [{ ...base, ...state, id: remoteJobRowId(job.jobId), name: "" }, ...files];
533
+ }
534
+ const sameRow = (a, b) => a.status === b.status && a.percent === b.percent && a.message === b.message && a.workspaceId === b.workspaceId && a.remote?.mine === b.remote?.mine;
479
535
  /**
480
536
  * The state of every upload the tracker follows — pure state, no I/O.
481
537
  *
@@ -498,6 +554,13 @@ const UploadTrackerStore = signalStore({ providedIn: "root" }, withDevtools("Upl
498
554
  }),
499
555
  /** Everything is over and nothing failed: the panel celebrates, then dismisses itself. */
500
556
  allDone: computed(() => uploads().length > 0 && uploads().every(u => u.status === "done")),
557
+ /** Everything is over, whether it worked or not: nothing moves any more, and the panel may be closed. */
558
+ settled: computed(() => uploads().length > 0 && uploads().every(isFinished)),
559
+ /**
560
+ * A failure on an upload of the current user's. It is theirs to read and close: the panel
561
+ * stays until they do. A failure on someone else's upload is only worth the usual delay.
562
+ */
563
+ authorFailed: computed(() => uploads().some(u => isFailed(u) && (!u.remote || u.remote.mine))),
501
564
  /** Per workspace, how far each phase is — what the tracker's progress bars show. */
502
565
  progress: computed(() => {
503
566
  const byWorkspace = new Map();
@@ -621,22 +684,62 @@ const UploadTrackerStore = signalStore({ providedIn: "root" }, withDevtools("Upl
621
684
  patchState(store, { uploads: next, jobIndexing });
622
685
  return changed;
623
686
  },
687
+ /**
688
+ * Feeds in the jobs the status API reports that this client is not following — the ones
689
+ * another user started, or its own from before a page reload. Each becomes a row (see
690
+ * `remoteRows`), updated on every call; a job this client sent itself is left to the
691
+ * local rows, and so is one the user already got rid of.
692
+ *
693
+ * @returns Whether anything changed, which paces the poller.
694
+ */
695
+ applyRemoteJobs(entries) {
696
+ const current = store.uploads();
697
+ const ignored = store.ignoredJobs();
698
+ const ownJobs = new Set(current.filter(u => !u.remote).map(jobOf));
699
+ const rows = new Map(current.map(u => [u.id, u]));
700
+ const workspaces = [...store.workspaces()];
701
+ let changed = false;
702
+ for (const entry of entries) {
703
+ if (ignored[entry.job.jobId] || ownJobs.has(entry.job.jobId))
704
+ continue;
705
+ for (const row of remoteRows(entry)) {
706
+ const known = rows.get(row.id);
707
+ if (known && sameRow(known, row))
708
+ continue;
709
+ rows.set(row.id, known ? { ...known, ...row } : row);
710
+ changed = true;
711
+ }
712
+ if (!workspaces.some(w => w.id === entry.workspaceId)) {
713
+ workspaces.push({ id: entry.workspaceId, name: entry.workspaceName });
714
+ changed = true;
715
+ }
716
+ }
717
+ if (changed)
718
+ patchState(store, { uploads: [...rows.values()], workspaces });
719
+ return changed;
720
+ },
624
721
  setConnection(connection) {
625
722
  patchState(store, { connection });
626
723
  },
627
724
  remove(id) {
628
- patchState(store, state => ({ uploads: state.uploads.filter(u => u.id !== id) }));
725
+ patchState(store, state => {
726
+ const uploads = state.uploads.filter(u => u.id !== id);
727
+ // The job is forgotten once its last row is gone; while one is left, the job is still followed.
728
+ const gone = state.uploads.filter(u => u.id === id && !uploads.some(other => jobOf(other) === jobOf(u)));
729
+ return { uploads, ignoredJobs: withIgnored(state.ignoredJobs, gone) };
730
+ });
629
731
  },
630
732
  /** Drops a finished workspace's files from the tracker; they stay counted as indexed. */
631
733
  removeWorkspace(workspaceId) {
632
734
  patchState(store, state => ({
633
735
  uploads: state.uploads.filter(u => u.workspaceId !== workspaceId),
634
736
  workspaces: state.workspaces.filter(w => w.id !== workspaceId),
635
- clearedCount: state.clearedCount + state.uploads.filter(u => u.workspaceId === workspaceId).length
737
+ clearedCount: state.clearedCount + state.uploads.filter(u => u.workspaceId === workspaceId).length,
738
+ ignoredJobs: withIgnored(state.ignoredJobs, state.uploads.filter(u => u.workspaceId === workspaceId))
636
739
  }));
637
740
  },
638
741
  reset() {
639
- patchState(store, initialState);
742
+ patchState(store, state => ({ ...initialState, ignoredJobs: withIgnored(state.ignoredJobs, state.uploads) }));
640
743
  }
641
744
  })));
642
745
 
@@ -727,6 +830,12 @@ function isJobTerminal(job) {
727
830
  function allJobs(res) {
728
831
  return (res.containers ?? []).flatMap(c => c.jobs ?? []);
729
832
  }
833
+ /** Like {@link allJobs}, but keeps the workspace each job belongs to. */
834
+ function containerJobs(res) {
835
+ return (res.containers ?? []).flatMap(c => (c.jobs ?? []).map(job => ({ job, containerId: c.containerId, containerName: c.containerName })));
836
+ }
837
+ /** How long the panel stays on screen once every upload is over. */
838
+ const FINISHED_UPLOAD_VISIBLE_MS = 2 * 60 * 1000;
730
839
 
731
840
  /**
732
841
  * `crypto.randomUUID()` is only available in a secure context (HTTPS, or HTTP on
@@ -952,9 +1061,6 @@ const UPLOAD_FACTORY = new InjectionToken("UPLOAD_FACTORY", {
952
1061
  factory: () => createTusUpload
953
1062
  });
954
1063
 
955
- // One page is enough to refresh what the open workspace shows: the detail view
956
- // itself loads page 1 at the same size.
957
- const DOCUMENT_STATUS_PAGE_SIZE = 100;
958
1064
  /**
959
1065
  * Sends files to a workspace and follows them until they are indexed.
960
1066
  *
@@ -969,6 +1075,10 @@ const DOCUMENT_STATUS_PAGE_SIZE = 100;
969
1075
  * speaks for both. What the user sees lives in `UploadTrackerStore`; this
970
1076
  * service only writes to it.
971
1077
  *
1078
+ * It never writes a file into a workspace's list: the list is the index, and a file
1079
+ * enters it only once the container listing returns it. What the service does for the
1080
+ * list is re-read that listing while something is in progress.
1081
+ *
972
1082
  * Root-scoped: an upload must outlive the view that started it. It registers its
973
1083
  * window and auth listeners once, and removes them when the application is destroyed.
974
1084
  */
@@ -1036,37 +1146,20 @@ class UploadService {
1036
1146
  const jobId = generateUUID();
1037
1147
  const fileCount = files.length;
1038
1148
  const totalBytes = files.reduce((total, f) => total + f.size, 0);
1039
- const uploadedBy = this.currentUser.user()?.name;
1040
- const now = new Date().toISOString();
1041
- const documents = files.map(file => ({
1042
- // A provisional id, which is also the key the tracker and this service follow the
1043
- // file by. It is NOT the server's: the listing names a document
1044
- // `/DocUpload/…|<containerId>/<fileName>`, and that is what DELETE and move expect.
1045
- // The first listing that shows the file swaps it in (see `adoptServerId`), so until
1046
- // then the document has no real id to act on.
1047
- id: `${jobId}/${file.name}`,
1048
- name: file.name,
1049
- workspaceId: workspace.id,
1050
- type: getDocumentType(file.name),
1051
- createdAt: now,
1052
- size: file.size,
1053
- status: "uploading",
1054
- percent: 0,
1055
- jobId,
1056
- uploadedBy
1057
- }));
1058
- this.workspaces.addDocuments(workspace.id, documents);
1059
- this.tracker.track(documents.map(d => ({ id: d.id, name: d.name, type: d.type })), workspace.id, workspace.name);
1060
- documents.forEach((document, i) => {
1061
- this.inFlight.add(document.id);
1062
- this.startUpload(document, files[i], { jobId, fileCount, totalBytes }, options.endpoint);
1149
+ // The id is the key the tracker and this service follow the file by. It is NOT the
1150
+ // server's: the listing names a document `/DocUpload/…|<containerId>/<fileName>`.
1151
+ const items = files.map(file => ({ id: `${jobId}/${file.name}`, workspaceId: workspace.id }));
1152
+ this.tracker.track(files.map((file, i) => ({ id: items[i].id, name: file.name, type: getDocumentType(file.name) })), workspace.id, workspace.name);
1153
+ items.forEach((item, i) => {
1154
+ this.inFlight.add(item.id);
1155
+ this.startUpload(item, files[i], { jobId, fileCount, totalBytes }, options.endpoint);
1063
1156
  });
1064
1157
  this.poller.start();
1065
- return documents.map(d => d.id);
1158
+ return items.map(item => item.id);
1066
1159
  }
1067
1160
  /**
1068
1161
  * Abandons a transfer and forgets it: stops it, and drops the file from the
1069
- * tracker and from the workspace's cached documents. Works while it is sending
1162
+ * tracker. Works while it is sending
1070
1163
  * and once it has failed; once the bytes are in, indexing is the server's and
1071
1164
  * there is nothing left to cancel.
1072
1165
  *
@@ -1083,7 +1176,6 @@ class UploadService {
1083
1176
  this.inFlight.delete(id);
1084
1177
  this.clearRetryNotice(id);
1085
1178
  this.tracker.remove(id);
1086
- this.workspaces.discardDocuments(entry.workspaceId, [this.storedId(entry.workspaceId, id)]);
1087
1179
  try {
1088
1180
  await entry.handle.abort(true);
1089
1181
  }
@@ -1098,7 +1190,6 @@ class UploadService {
1098
1190
  if (!entry || entry.refused || upload?.status !== "error")
1099
1191
  return;
1100
1192
  this.tracker.restart(id);
1101
- this.patchStored(entry.workspaceId, id, { status: "uploading", percent: 0 });
1102
1193
  this.inFlight.add(id);
1103
1194
  entry.handle.resume();
1104
1195
  this.poller.start();
@@ -1115,17 +1206,15 @@ class UploadService {
1115
1206
  }
1116
1207
  /**
1117
1208
  * Tells the service which workspace is open, so the status polling can be
1118
- * scoped to it (the API documents that as the cheaper lookup) and refresh what
1119
- * it shows. `null` when none is. Starts the polling when the workspace holds
1120
- * documents that are not finished yet: a page reload loses every local tus
1121
- * handle, so the listing is the only way to know.
1209
+ * scoped to it (the API documents that as the cheaper lookup) and re-read its
1210
+ * listing. `null` when none is. Starts the polling: a page reload loses every
1211
+ * local tus handle and the listing no longer says what is in progress, so the
1212
+ * status API is the only way to know. The loop stops by itself after one tick
1213
+ * when nothing is.
1122
1214
  */
1123
1215
  watch(workspaceId) {
1124
1216
  this.focusedWorkspaceId = workspaceId;
1125
- if (!workspaceId)
1126
- return;
1127
- const unfinished = this.workspaces.documentsOf(workspaceId).some(d => d.status === "uploading" || d.status === "indexing");
1128
- if (unfinished)
1217
+ if (workspaceId)
1129
1218
  this.poller.start();
1130
1219
  }
1131
1220
  startPolling() {
@@ -1135,11 +1224,11 @@ class UploadService {
1135
1224
  this.poller.stop();
1136
1225
  }
1137
1226
  // ── sending ──
1138
- startUpload(document, file, job, endpoint) {
1139
- const workspaceId = document.workspaceId;
1227
+ startUpload(item, file, job, endpoint) {
1228
+ const workspaceId = item.workspaceId;
1140
1229
  if (!endpoint) {
1141
- this.inFlight.delete(document.id);
1142
- this.markError(document);
1230
+ this.inFlight.delete(item.id);
1231
+ this.markError(item);
1143
1232
  return;
1144
1233
  }
1145
1234
  const entry = {
@@ -1161,25 +1250,25 @@ class UploadService {
1161
1250
  // again once the user is back.
1162
1251
  void this.recoverSessionAndResume();
1163
1252
  if (this.signedOut) {
1164
- this.clearRetryNotice(document.id);
1253
+ this.clearRetryNotice(item.id);
1165
1254
  return false;
1166
1255
  }
1167
1256
  }
1168
1257
  else if (FATAL_UPLOAD_STATUSES.has(status)) {
1169
1258
  entry.refused = true;
1170
- this.clearRetryNotice(document.id);
1259
+ this.clearRetryNotice(item.id);
1171
1260
  return false;
1172
1261
  }
1173
1262
  // tus waits retryDelays[attempt] before this attempt and passes that same
1174
1263
  // index here, so the banner's countdown is the real delay, not an estimate.
1175
- this.noteRetryScheduled(document.id, attempt);
1264
+ this.noteRetryScheduled(item.id, attempt);
1176
1265
  return true;
1177
1266
  },
1178
1267
  // Anything that came back cleanly means the connection works again for this
1179
1268
  // upload, whatever the retry bookkeeping still says.
1180
1269
  responded: status => {
1181
1270
  if (status < 400)
1182
- this.noteHealthy(document.id);
1271
+ this.noteHealthy(item.id);
1183
1272
  },
1184
1273
  // A recovery kicked off by a previous 401 may still be in flight, and
1185
1274
  // carrying the token it is fetching is the entire point of this retry.
@@ -1192,67 +1281,42 @@ class UploadService {
1192
1281
  // processed: on a fast transfer a stale event can still arrive after
1193
1282
  // success moved the file to indexing. Drop anything that comes once the
1194
1283
  // upload phase is over, so it cannot stomp the indexing percentage.
1195
- if (this.tracker.uploads().find(u => u.id === document.id)?.status !== "uploading")
1284
+ if (this.tracker.uploads().find(u => u.id === item.id)?.status !== "uploading")
1196
1285
  return;
1197
- this.tracker.setProgress(document.id, bytesTotal ? (bytesUploaded / bytesTotal) * 100 : 0);
1286
+ this.tracker.setProgress(item.id, bytesTotal ? (bytesUploaded / bytesTotal) * 100 : 0);
1198
1287
  },
1199
1288
  success: () => {
1200
1289
  // Bytes are in; indexing is now the server's business, so the poller owns
1201
1290
  // this document from here. Nothing left to resume.
1202
- this.noteHealthy(document.id);
1203
- this.uploads.delete(document.id);
1204
- this.markIndexing(document);
1291
+ this.noteHealthy(item.id);
1292
+ this.uploads.delete(item.id);
1293
+ this.markIndexing(item);
1205
1294
  this.poller.start();
1206
1295
  },
1207
1296
  failed: () => {
1208
1297
  // The handle is kept: the retry budget is spent, but the upload is not lost —
1209
1298
  // `retry()` can pick it up again and tus resumes from the server's offset.
1210
- this.clearRetryNotice(document.id);
1211
- this.inFlight.delete(document.id);
1212
- this.markError(document, { retryable: !entry.refused });
1299
+ this.clearRetryNotice(item.id);
1300
+ this.inFlight.delete(item.id);
1301
+ this.markError(item, { retryable: !entry.refused });
1213
1302
  }
1214
1303
  }
1215
1304
  })
1216
1305
  };
1217
- this.uploads.set(document.id, entry);
1306
+ this.uploads.set(item.id, entry);
1218
1307
  entry.handle.begin();
1219
1308
  }
1220
- // ── what a document's state change writes where ──
1221
- // A document has two names. The tracker, `inFlight` and `uploads` know it by its
1222
- // provisional key (`<jobId>/<fileName>`, which never changes); the workspace store
1223
- // knows it by whatever id it holds right now — the same key until the server's id
1224
- // is adopted, the server's after. These helpers bridge the two.
1225
- /** The key a cached document is followed by in the tracker. */
1226
- keyOf(document) {
1227
- return document.jobId ? `${document.jobId}/${document.name}` : document.id;
1228
- }
1229
- /** The id the workspace store holds for the file followed under `key`. */
1230
- storedId(workspaceId, key) {
1231
- return this.workspaces.documentsOf(workspaceId).find(d => this.keyOf(d) === key)?.id ?? key;
1232
- }
1233
- patchStored(workspaceId, key, patch) {
1234
- this.workspaces.patchDocument(workspaceId, this.storedId(workspaceId, key), patch);
1235
- }
1236
- markIndexing(document) {
1237
- const key = this.keyOf(document);
1238
- this.patchStored(document.workspaceId, key, { status: "indexing" });
1239
- this.tracker.setIndexing(key);
1240
- }
1241
- markComplete(document) {
1242
- const key = this.keyOf(document);
1243
- this.patchStored(document.workspaceId, key, { status: null });
1244
- this.tracker.setDone(key);
1245
- }
1246
- markError(document, options = {}) {
1247
- const key = this.keyOf(document);
1248
- this.patchStored(document.workspaceId, key, { status: "error" });
1249
- this.tracker.setError(key, options);
1309
+ // ── what a file's state change writes ──
1310
+ markIndexing(item) {
1311
+ this.tracker.setIndexing(item.id);
1312
+ }
1313
+ markError(item, options = {}) {
1314
+ this.tracker.setError(item.id, options);
1250
1315
  }
1251
1316
  // ── following indexing ──
1252
1317
  //
1253
- // There are two endpoints, at two grains, and both are needed:
1254
- // - GET .../container/{id}/documents -> status + percent PER DOCUMENT
1255
- // - GET .../document-upload/status -> aggregate, errors[] and rejected[] PER JOB
1318
+ // - GET .../document-upload/status -> the tracker: aggregate, errors[] and rejected[] PER JOB
1319
+ // - GET .../container/{id}/documents -> the workspace's list, which is the index and nothing else
1256
1320
  async pollUploadStatus(signal) {
1257
1321
  try {
1258
1322
  const result = await this.runPollTick(signal);
@@ -1277,84 +1341,51 @@ class UploadService {
1277
1341
  // the tracker still follows a batch after the user navigates away from it.
1278
1342
  const response = await fetchUploadStatus(workspaceId ? { containerId: workspaceId } : {}, { signal });
1279
1343
  const jobs = allJobs(response);
1280
- // Both halves can move on their own — a rejection can surface with no workspace
1281
- // open at all — so the tick reports the union, otherwise the idle backoff would
1282
- // widen while things are still happening.
1283
- let changed = this.tracker.applyJobs(jobs);
1344
+ // Jobs started elsewhere come first, so that `applyJobs` finds their rows to update.
1345
+ const remote = await this.remoteJobs(response, signal);
1346
+ let changed = this.tracker.applyRemoteJobs(remote);
1347
+ changed = this.tracker.applyJobs(jobs) || changed;
1284
1348
  this.settleFinishedUploads();
1285
- let pending = jobs.some(job => !isJobTerminal(job));
1286
- if (workspaceId) {
1287
- const listing = await listContainerDocuments(workspaceId, { page: 1, pageSize: DOCUMENT_STATUS_PAGE_SIZE }, { signal });
1288
- const applied = this.applyDocumentStatuses(workspaceId, listing.documents);
1289
- pending = pending || applied.pending;
1290
- changed = changed || applied.changed;
1291
- }
1349
+ const pending = jobs.some(job => !isJobTerminal(job));
1350
+ // The list follows the index, not the status: a file shows up once the listing returns it.
1351
+ // The last tick of a loop reads it too, which is when a file that just got indexed comes in.
1352
+ if (workspaceId)
1353
+ changed = (await this.refreshListing(workspaceId, signal)) || changed;
1292
1354
  return { pending: pending || this.inFlight.size > 0, changed };
1293
1355
  }
1294
1356
  /**
1295
- * The job-level status can finish a file the document listing has not reported
1296
- * (no workspace open, or a listing that lags): mirror what the tracker now says
1297
- * onto the cached document and release the poller's hold on it.
1357
+ * The jobs of the response the panel should show although this client did not send
1358
+ * them. The server decides which ones the response holds — those in progress or just
1359
+ * ended, on the workspaces the user can write to — so none is filtered by role or date
1360
+ * here; only, with a workspace open, those that belong to it are kept. Whether the user
1361
+ * started one is read from `initiatedBy`, which is their `userId`, so it holds after a
1362
+ * page reload too.
1298
1363
  */
1299
- settleFinishedUploads() {
1300
- for (const upload of this.tracker.uploads()) {
1301
- if (upload.status !== "done" || !this.inFlight.has(upload.id))
1364
+ async remoteJobs(response, signal) {
1365
+ const me = (await this.currentUser.whenReady())?.id;
1366
+ if (signal.aborted)
1367
+ return [];
1368
+ const entries = [];
1369
+ for (const { job, containerId, containerName } of containerJobs(response)) {
1370
+ if (this.focusedWorkspaceId && this.focusedWorkspaceId !== containerId)
1302
1371
  continue;
1303
- this.inFlight.delete(upload.id);
1304
- this.patchStored(upload.workspaceId, upload.id, { status: null });
1372
+ const workspaceName = this.workspaces.workspace(containerId)?.name ?? containerName;
1373
+ entries.push({ job, workspaceId: containerId, workspaceName, mine: !!me && job.initiatedBy === me });
1305
1374
  }
1375
+ return entries;
1306
1376
  }
1307
- applyDocumentStatuses(workspaceId, wireDocuments) {
1308
- const locals = this.workspaces.documentsOf(workspaceId);
1309
- let pending = false;
1310
- let changed = false;
1311
- for (const wire of wireDocuments) {
1312
- const next = mapDocumentStatus(wire.status);
1313
- if (next === "uploading" || next === "indexing")
1314
- pending = true;
1315
- // The listing names a document `/DocUpload/…|<containerId>/<fileName>`, ours is
1316
- // `<jobId>/<fileName>`: the (job, name) pair is what both sides agree on.
1317
- const document = locals.find(d => d.id === wire.id || (!!wire.jobId && d.jobId === wire.jobId && d.name === wire.name));
1318
- if (!document)
1319
- continue;
1320
- const key = this.keyOf(document);
1321
- // From now on the store holds the id the server will recognise: delete and move send it.
1322
- if (document.id !== wire.id)
1323
- this.workspaces.patchDocument(workspaceId, document.id, { id: wire.id });
1324
- // While tus is still sending, the local byte-level progress is ahead of anything
1325
- // the server can report: it owns the percentage until tus hands over. An error
1326
- // is the one thing worth taking early.
1327
- if (this.inFlight.has(key) && document.status === "uploading") {
1328
- if (next === "error") {
1329
- this.inFlight.delete(key);
1330
- this.markError(document);
1331
- changed = true;
1332
- }
1333
- continue;
1334
- }
1335
- if (next === "indexing" && document.status !== "indexing") {
1336
- this.markIndexing(document);
1337
- changed = true;
1338
- }
1339
- else if (next === null && document.status !== null) {
1340
- this.inFlight.delete(key);
1341
- this.markComplete(document);
1342
- changed = true;
1343
- }
1344
- else if (next === "error" && document.status !== "error") {
1345
- this.inFlight.delete(key);
1346
- this.markError(document);
1347
- changed = true;
1348
- }
1349
- else if (next === "uploading" && typeof wire.percent === "number" && document.percent !== wire.percent) {
1350
- // Not ours to transfer (another user's upload, or our own seen again after a
1351
- // reload): the server's percentage is all there is.
1352
- this.workspaces.patchDocument(workspaceId, wire.id, { percent: wire.percent });
1353
- this.tracker.setProgress(key, wire.percent);
1354
- changed = true;
1355
- }
1377
+ /** Re-reads the workspace's listing. @returns Whether it differs from what was shown. */
1378
+ async refreshListing(workspaceId, signal) {
1379
+ const before = JSON.stringify(this.workspaces.documentsOf(workspaceId));
1380
+ await this.workspaces.loadDocuments(workspaceId, signal);
1381
+ return JSON.stringify(this.workspaces.documentsOf(workspaceId)) !== before;
1382
+ }
1383
+ /** The job-level status can finish a file this client was still holding the poller for: release it. */
1384
+ settleFinishedUploads() {
1385
+ for (const upload of this.tracker.uploads()) {
1386
+ if (upload.status === "done")
1387
+ this.inFlight.delete(upload.id);
1356
1388
  }
1357
- return { pending, changed };
1358
1389
  }
1359
1390
  // ── connection loss & session recovery ──
1360
1391
  //
@@ -2314,6 +2345,62 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
2314
2345
  args: [{ name: "highlightMatch" }]
2315
2346
  }] });
2316
2347
 
2348
+ /**
2349
+ * Keeps a view fresh while it stays open, without the user ever asking: `tick`
2350
+ * re-fetches whatever the view shows and reports whether the result differs
2351
+ * from before — the loop speeds back up on a change and backs off while idle,
2352
+ * exactly like {@link StatusPoller} already does for upload status.
2353
+ *
2354
+ * Paused while the browser reports no network (resumed the moment it is back),
2355
+ * and fully stopped once the injecting component is destroyed. The very first
2356
+ * scheduled tick is a no-op: whatever calls `start()` has normally just
2357
+ * fetched on its own, so re-fetching the same thing instantly would be
2358
+ * redundant — the first real poll happens one `intervalMs` later.
2359
+ *
2360
+ * Must be called from an injection context.
2361
+ */
2362
+ function injectLiveRefresh(tick, options = {}) {
2363
+ assertInInjectionContext(injectLiveRefresh);
2364
+ let shouldRun = false;
2365
+ let first = true;
2366
+ const poller = new StatusPoller({
2367
+ intervalMs: options.intervalMs,
2368
+ maxIntervalMs: options.maxIntervalMs,
2369
+ tick: async (signal) => {
2370
+ if (first) {
2371
+ first = false;
2372
+ return { pending: true, changed: true };
2373
+ }
2374
+ return { pending: true, changed: await tick(signal) };
2375
+ }
2376
+ });
2377
+ if (typeof window !== "undefined") {
2378
+ const resume = () => {
2379
+ if (shouldRun)
2380
+ poller.start();
2381
+ };
2382
+ const pause = () => poller.stop();
2383
+ window.addEventListener("online", resume);
2384
+ window.addEventListener("offline", pause);
2385
+ inject(DestroyRef).onDestroy(() => {
2386
+ window.removeEventListener("online", resume);
2387
+ window.removeEventListener("offline", pause);
2388
+ });
2389
+ }
2390
+ inject(DestroyRef).onDestroy(() => poller.stop());
2391
+ return {
2392
+ start() {
2393
+ shouldRun = true;
2394
+ if (typeof navigator === "undefined" || navigator.onLine)
2395
+ poller.start();
2396
+ },
2397
+ stop() {
2398
+ shouldRun = false;
2399
+ poller.stop();
2400
+ }
2401
+ };
2402
+ }
2403
+
2317
2404
  /**
2318
2405
  * Maps a caught error to the message shown in an inline error banner.
2319
2406
  *
@@ -3063,7 +3150,9 @@ const TABS = [
3063
3150
  * Each time it is shown it refreshes: workspaces created by others since the last
3064
3151
  * time appear, and the document counts match the server. The list already on
3065
3152
  * screen stays until the answer lands, and a failure leaves it untouched behind
3066
- * an inline error.
3153
+ * an inline error. While it stays shown, it keeps polling in the background
3154
+ * (`injectLiveRefresh`) so a workspace created by someone else — one the current
3155
+ * user only reads, say — appears on its own, with no action needed.
3067
3156
  */
3068
3157
  class WorkspaceList {
3069
3158
  /** A workspace was chosen for opening. */
@@ -3072,6 +3161,7 @@ class WorkspaceList {
3072
3161
  createRequested = output();
3073
3162
  store = inject(WorkspacesStore);
3074
3163
  dialogs = inject(DialogService);
3164
+ uploads = inject(UploadService);
3075
3165
  transloco = inject(TranslocoService);
3076
3166
  tabs = TABS;
3077
3167
  viewMode = signal("grid", ...(ngDevMode ? [{ debugName: "viewMode" }] : []));
@@ -3088,7 +3178,24 @@ class WorkspaceList {
3088
3178
  key: (workspace) => workspace.id,
3089
3179
  selectable: workspace => canWrite(workspace.currentUserRole)
3090
3180
  });
3181
+ // Background polling, started once the list has something to compare against:
3182
+ // each tick re-reloads and compares the full result to what was cached before
3183
+ // it, not just the set of ids — a workspace renamed, re-tagged or whose access
3184
+ // changed keeps the same id, and would otherwise look unchanged forever, which
3185
+ // would both hide the edit and widen the backoff on ticks that are not actually idle.
3186
+ liveRefresh = injectLiveRefresh(async () => {
3187
+ const before = JSON.stringify(this.store.workspaces());
3188
+ await this.store.reload();
3189
+ const changed = JSON.stringify(this.store.workspaces()) !== before;
3190
+ if (changed)
3191
+ this.selection.exit(); // the ticks belonged to the list that was there before
3192
+ // An upload someone else is running on a workspace listed here: only the status API tells.
3193
+ // It stops by itself after a tick when nothing is moving.
3194
+ this.uploads.startPolling();
3195
+ return changed;
3196
+ }, { intervalMs: 20_000, maxIntervalMs: 120_000 });
3091
3197
  constructor() {
3198
+ this.uploads.startPolling();
3092
3199
  void this.load();
3093
3200
  }
3094
3201
  /** Shows what is cached at once and refreshes behind it, or loads for the first time. */
@@ -3097,12 +3204,14 @@ class WorkspaceList {
3097
3204
  if (this.store.loaded()) {
3098
3205
  this.status.set("ready");
3099
3206
  await this.refresh();
3207
+ this.liveRefresh.start();
3100
3208
  return;
3101
3209
  }
3102
3210
  this.status.set("loading");
3103
3211
  try {
3104
3212
  await this.store.ensureLoaded();
3105
3213
  this.status.set("ready");
3214
+ this.liveRefresh.start();
3106
3215
  }
3107
3216
  catch (error) {
3108
3217
  this.loadError.set(workspaceErrorMessage(error));
@@ -3421,6 +3530,15 @@ function truncateName(name, max) {
3421
3530
  }
3422
3531
  return `${name.slice(0, max - 1)}…`;
3423
3532
  }
3533
+ /**
3534
+ * What to call a tracked row: the file's name — or, for a job started elsewhere whose files
3535
+ * the status API does not list, whose job it is.
3536
+ */
3537
+ function uploadLabel(upload, max) {
3538
+ if (upload.name)
3539
+ return { text: truncateName(upload.name, max) };
3540
+ return trackerMessage(upload.remote?.mine ? "ownUpload" : "remoteUpload");
3541
+ }
3424
3542
  /** `m:ss` for a duration in ms, clamped at 0 so a late tick cannot go negative. */
3425
3543
  function formatClock(ms) {
3426
3544
  const seconds = Math.max(0, Math.round(ms / 1000));
@@ -3889,6 +4007,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
3889
4007
  }]
3890
4008
  }], propDecorators: { document: [{ type: i0.Input, args: [{ isSignal: true, alias: "document", required: true }] }], workspaceRole: [{ type: i0.Input, args: [{ isSignal: true, alias: "workspaceRole", required: true }] }], query: [{ type: i0.Input, args: [{ isSignal: true, alias: "query", required: false }] }], active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: false }] }], selecting: [{ type: i0.Input, args: [{ isSignal: true, alias: "selecting", required: false }] }], selected: [{ type: i0.Input, args: [{ isSignal: true, alias: "selected", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], opened: [{ type: i0.Output, args: ["opened"] }], deleteRequested: [{ type: i0.Output, args: ["deleteRequested"] }], selectedChange: [{ type: i0.Output, args: ["selectedChange"] }] } });
3891
4009
 
4010
+ /**
4011
+ * The file-search glyph on a teal tile — the same tile `EmptyFolderIcon` uses
4012
+ * for an empty workspace or an empty list, for a workspace or document the
4013
+ * server no longer has.
4014
+ */
4015
+ class NotFoundIcon {
4016
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NotFoundIcon, deps: [], target: i0.ɵɵFactoryTarget.Component });
4017
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.29", type: NotFoundIcon, isStandalone: true, selector: "not-found-icon", host: { attributes: { "data-testid": "not-found-icon" }, classAttribute: "mb-1 inline-flex size-14 shrink-0 items-center justify-center rounded-(--radius-xl) bg-(--teal-100,#E0F4F1) text-(--teal-400,#4EBFB0)" }, ngImport: i0, template: `<file-search-icon class="size-6" />`, isInline: true, dependencies: [{ kind: "component", type: FileSearchIcon, selector: "file-search-icon, FileSearchIcon, filesearchicon", inputs: ["class"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4018
+ }
4019
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: NotFoundIcon, decorators: [{
4020
+ type: Component,
4021
+ args: [{
4022
+ selector: "not-found-icon",
4023
+ imports: [FileSearchIcon],
4024
+ changeDetection: ChangeDetectionStrategy.OnPush,
4025
+ host: {
4026
+ class: "mb-1 inline-flex size-14 shrink-0 items-center justify-center rounded-(--radius-xl) bg-(--teal-100,#E0F4F1) text-(--teal-400,#4EBFB0)",
4027
+ "data-testid": "not-found-icon"
4028
+ },
4029
+ template: `<file-search-icon class="size-6" />`
4030
+ }]
4031
+ }] });
4032
+
3892
4033
  /** The files staged for upload, before anything is sent. */
3893
4034
  class UploadFileList {
3894
4035
  files = input.required(...(ngDevMode ? [{ debugName: "files" }] : []));
@@ -5643,6 +5784,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
5643
5784
  * shown: it says what the user asked for (`back`, `deleted`, `askFollowUp`) and whoever
5644
5785
  * hosts it decides. The document open in the preview is the `previewedDocumentId` model.
5645
5786
  *
5787
+ * While a workspace stays open, it keeps polling its documents in the background
5788
+ * (`injectLiveRefresh`), so a document another user adds to it shows up on its own.
5789
+ *
5646
5790
  * Only someone who can write sees the edit, share, delete, upload and select controls.
5647
5791
  */
5648
5792
  class WorkspaceDetail {
@@ -5673,11 +5817,13 @@ class WorkspaceDetail {
5673
5817
  refreshing = signal(false, ...(ngDevMode ? [{ debugName: "refreshing" }] : []));
5674
5818
  loadingMore = signal(false, ...(ngDevMode ? [{ debugName: "loadingMore" }] : []));
5675
5819
  loadMoreError = signal(null, ...(ngDevMode ? [{ debugName: "loadMoreError" }] : []));
5820
+ /** The background refresh found the workspace gone from the server (deleted elsewhere). */
5821
+ deletedElsewhere = signal(false, ...(ngDevMode ? [{ debugName: "deletedElsewhere" }] : []));
5676
5822
  workspace = computed(() => this.store.workspace(this.workspaceId()), ...(ngDevMode ? [{ debugName: "workspace" }] : []));
5677
- // Only what is really uploaded or being indexed is listed: transfers and failures
5678
- // belong to the upload tracker, not to the workspace. A document with no status at
5679
- // all is one the server does not track — it is simply there, so it stays listed.
5680
- documents = computed(() => this.store.documentsOf(this.workspaceId()).filter(d => d.status !== "uploading" && d.status !== "error"), ...(ngDevMode ? [{ debugName: "documents" }] : []));
5823
+ // The list is the index: only a document the listing returns as indexed is shown. Transfers,
5824
+ // indexing and failures belong to the upload tracker, not to the workspace. A document with no
5825
+ // status at all is one the server does not track — it is simply there, so it stays listed.
5826
+ documents = computed(() => this.store.documentsOf(this.workspaceId()).filter(d => !d.status), ...(ngDevMode ? [{ debugName: "documents" }] : []));
5681
5827
  previewed = computed(() => this.documents().find(d => d.id === this.previewedDocumentId()) ?? null, ...(ngDevMode ? [{ debugName: "previewed" }] : []));
5682
5828
  /** The width the user dragged the preview to, if they did. */
5683
5829
  userPreviewWidth = signal(null, ...(ngDevMode ? [{ debugName: "userPreviewWidth" }] : []));
@@ -5698,6 +5844,43 @@ class WorkspaceDetail {
5698
5844
  // Selecting only serves deleting, which a reader cannot do.
5699
5845
  selectable: () => this.writable()
5700
5846
  });
5847
+ // Background polling, re-scoped to whatever `workspaceId` is current on each
5848
+ // tick (no restart needed when the user switches workspaces): refreshes both
5849
+ // the workspace's own metadata (title, description, tags, owners/readers,
5850
+ // the current user's role) and its documents, so an edit or an access change
5851
+ // made elsewhere shows up here too, not just additions to the document list.
5852
+ // Comparing full snapshots (not just ids) catches an in-place edit that an
5853
+ // id-only diff would miss — a renamed workspace, a demoted role — and keeps
5854
+ // the backoff from widening on a tick that only looks unchanged by id.
5855
+ liveRefresh = injectLiveRefresh(async (signal) => {
5856
+ const id = this.workspaceId();
5857
+ const beforeWorkspace = JSON.stringify(this.store.workspace(id));
5858
+ const beforeDocuments = JSON.stringify(this.store.documentsOf(id));
5859
+ try {
5860
+ await Promise.all([this.store.fetch(id), this.store.loadDocuments(id, signal)]);
5861
+ }
5862
+ catch (error) {
5863
+ // Someone else deleted it: flag it and stop polling — there is nothing
5864
+ // left here to discover. The workspace stays in the shared cache (so its
5865
+ // name/description keep showing instead of the view going blank); `leave()`
5866
+ // drops it once the user actually goes back to the list.
5867
+ if (error instanceof ApiError && error.status === 404 && this.workspaceId() === id) {
5868
+ this.deletedElsewhere.set(true);
5869
+ this.selection.exit();
5870
+ this.liveRefresh.stop();
5871
+ return true;
5872
+ }
5873
+ throw error;
5874
+ }
5875
+ if (this.workspaceId() !== id)
5876
+ return false; // the user moved to another workspace meanwhile
5877
+ const documentsChanged = JSON.stringify(this.store.documentsOf(id)) !== beforeDocuments;
5878
+ const workspaceChanged = JSON.stringify(this.store.workspace(id)) !== beforeWorkspace;
5879
+ // Every tick, changed or not: an upload someone else just started shows no document yet, and the
5880
+ // tracker only learns of it from the status API. It stops by itself after a tick when nothing is moving.
5881
+ this.uploads.watch(id);
5882
+ return documentsChanged || workspaceChanged;
5883
+ }, { intervalMs: 15_000, maxIntervalMs: 90_000 });
5701
5884
  constructor() {
5702
5885
  effect(() => {
5703
5886
  this.workspaceId();
@@ -5723,8 +5906,19 @@ class WorkspaceDetail {
5723
5906
  this.query.set("");
5724
5907
  this.refreshError.set(null);
5725
5908
  this.loadMoreError.set(null);
5909
+ this.deletedElsewhere.set(false);
5726
5910
  await this.load();
5727
5911
  }
5912
+ /**
5913
+ * Leaving from here: once the background refresh found the workspace gone,
5914
+ * the cache still holds it (so its name kept showing) — drop it now, so a
5915
+ * stale card does not show it back on the list.
5916
+ */
5917
+ leave() {
5918
+ if (this.deletedElsewhere())
5919
+ this.store.forget(this.workspaceId());
5920
+ this.back.emit();
5921
+ }
5728
5922
  /** Reads the workspace (when it is not cached) and its documents, for the workspace currently shown. */
5729
5923
  async load() {
5730
5924
  const id = this.workspaceId();
@@ -5747,6 +5941,7 @@ class WorkspaceDetail {
5747
5941
  return;
5748
5942
  this.status.set("ready");
5749
5943
  this.uploads.watch(id);
5944
+ this.liveRefresh.start();
5750
5945
  }
5751
5946
  /** Reads the documents again without leaving the view; a failure leaves the ones shown as they are. */
5752
5947
  async refresh() {
@@ -5846,7 +6041,7 @@ class WorkspaceDetail {
5846
6041
  return "key" in message ? this.transloco.translate(message.key, message.params) : message.text;
5847
6042
  }
5848
6043
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: WorkspaceDetail, deps: [], target: i0.ɵɵFactoryTarget.Component });
5849
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: WorkspaceDetail, isStandalone: true, selector: "workspace-detail", inputs: { workspaceId: { classPropertyName: "workspaceId", publicName: "workspaceId", isSignal: true, isRequired: true, transformFunction: null }, uploadEndpoint: { classPropertyName: "uploadEndpoint", publicName: "uploadEndpoint", isSignal: true, isRequired: false, transformFunction: null }, previewedDocumentId: { classPropertyName: "previewedDocumentId", publicName: "previewedDocumentId", isSignal: true, isRequired: false, transformFunction: null }, queryName: { classPropertyName: "queryName", publicName: "queryName", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { previewedDocumentId: "previewedDocumentIdChange", back: "back", deleted: "deleted", workspaceUpdated: "workspaceUpdated", askFollowUp: "askFollowUp" }, host: { classAttribute: "flex h-full min-h-0" }, providers: [provideTranslocoScope("document-workspaces")], ngImport: i0, template: "\n <div class=\"@container flex min-h-0 min-w-0 flex-1 flex-col p-3 pb-2 md:p-8 md:pb-4\">\n <div class=\"mb-3 flex flex-wrap items-center gap-x-3 gap-y-2\">\n <button class=\"w-6\" size=\"sm\"\n variant=\"ghost\"\n [iconOnly]=\"true\"\n [attr.aria-label]=\"'documentWorkspaces.detail.back' | transloco\"\n [tooltip]=\"'documentWorkspaces.detail.back' | transloco\"\n (click)=\"back.emit()\">\n <chevron-down-icon class=\"rotate-90\" />\n </button>\n\n @if (workspace(); as workspace) {\n <h1 class=\"min-w-28 flex-[1_1_16rem] text-2xl leading-[31px] font-bold break-words text-(--font-neutral-black)\">{{ workspace.name }}</h1>\n\n <!-- Beside the title while they fit next to its 16rem, on a line of their own under it once they do not. -->\n <div class=\"ml-auto flex shrink-0 items-center gap-1\">\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\"\n [writerOnly]=\"workspace.currentUserRole\"\n (click)=\"confirmDeleteWorkspace(workspace)\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"share(workspace)\">\n <user-round-icon />\n {{ \"documentWorkspaces.share.title\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"edit(workspace)\">\n <pen-to-square-icon />\n {{ \"documentWorkspaces.common.edit\" | transloco }}\n </button>\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"w-6\"\n [iconOnly]=\"true\"\n [disabled]=\"refreshing()\"\n [attr.aria-label]=\"'documentWorkspaces.common.refresh' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.refresh' | transloco\"\n (click)=\"refresh()\">\n <refresh-icon />\n </button>\n </div>\n }\n </div>\n\n @if (workspace(); as workspace) {\n @if (workspace.description) {\n <p class=\"mb-3 text-base text-(--font-primary-base)\">{{ workspace.description }}</p>\n }\n @if (workspace.tags.length) {\n <p class=\"mb-6 flex items-center gap-2 text-sm text-(--font-primary-base)\" data-testid=\"tags\">\n <tag-icon class=\"size-4\" />\n {{ workspace.tags.join(\", \") }}\n </p>\n }\n\n <div class=\"mb-3 flex flex-wrap items-center gap-2\">\n <div role=\"group\" class=\"inline-flex gap-1 rounded-full bg-(--bg-primary-lighter) p-0.5\">\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'grid'\"\n [attr.aria-label]=\"'documentWorkspaces.common.gridView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.gridView' | transloco\"\n (click)=\"viewMode.set('grid')\">\n <grid-icon class=\"size-3.5\" />\n </button>\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'list'\"\n [attr.aria-label]=\"'documentWorkspaces.common.listView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.listView' | transloco\"\n (click)=\"viewMode.set('list')\">\n <list-icon class=\"size-3.5\" />\n </button>\n </div>\n\n <div\n role=\"search\"\n class=\"flex h-9 min-w-32 flex-[0_1_15rem] items-center gap-2 rounded-full border border-(--stroke-primary-light) bg-(--bg-neutral-white) px-3 focus-within:border-(--stroke-primary-base)\">\n <magnifying-glass-icon class=\"size-4 shrink-0 text-(--font-primary-muted)\" />\n <input\n type=\"text\"\n class=\"min-w-0 flex-1 bg-transparent text-xs text-(--font-neutral-black) outline-none placeholder:text-(--font-primary-muted)\"\n [value]=\"query()\"\n [attr.placeholder]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n [attr.aria-label]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n (input)=\"onSearch($event)\" />\n @if (query()) {\n <button variant=\"none\" type=\"button\" class=\"inline-flex\" [attr.aria-label]=\"'documentWorkspaces.common.clearSearch' | transloco\" (click)=\"query.set('')\">\n <x-mark-icon class=\"size-4\" />\n </button>\n }\n </div>\n\n <button class=\"gap-1.5\" size=\"sm\" variant=\"primary\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"upload(workspace)\">\n <upload-icon />\n {{ \"documentWorkspaces.detail.upload\" | transloco }}\n </button>\n\n @if (selection.selectableItems().length) {\n @if (selection.active()) {\n <div class=\"ml-auto flex items-center gap-2 @max-xl:order-last @max-xl:ml-0 @max-xl:basis-full\" data-testid=\"selection-bar\">\n <button variant=\"ghost\" size=\"xs\" (click)=\"selection.toggleAll()\">\n {{ (selection.allSelected() ? \"documentWorkspaces.common.unselectAll\" : \"documentWorkspaces.common.selectAll\") | transloco }}\n </button>\n <button class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\" variant=\"ghost\" size=\"xs\" [disabled]=\"!selection.count()\" (click)=\"deleteSelected()\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n </div>\n }\n <button variant=\"ghost\" size=\"xs\" class=\"@max-xl:ml-auto\" [class.ml-auto]=\"!selection.active()\" (click)=\"selection.toggleMode()\">\n {{ (selection.active() ? \"documentWorkspaces.common.cancel\" : \"documentWorkspaces.common.select\") | transloco }}\n </button>\n }\n </div>\n\n <p class=\"mb-6 text-sm text-(--font-primary-base)\" aria-live=\"polite\" data-testid=\"count\">\n @if (selection.active()) {\n {{ \"documentWorkspaces.detail.selectedDocumentCount\" | transloco: { count: selection.count() } }}\n } @else {\n {{ \"documentWorkspaces.detail.fileCount\" | transloco: { count: workspace.documentCount } }}\n }\n </p>\n }\n\n @if (refreshError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.refreshError\" | transloco: { error: message } }}</p>\n }\n\n <!-- Only the documents scroll: the title, the toolbar and the count stay in place. -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (status()) {\n @case (\"loading\") {\n <p class=\"text-(--font-primary-muted) text-sm\">{{ \"documentWorkspaces.detail.docsLoading\" | transloco }}</p>\n }\n @case (\"error\") {\n <div class=\"flex items-center gap-3 text-sm\" data-testid=\"load-error\">\n <span class=\"text-(--font-error-base)\">\n @if (loadError(); as message) {\n <workspace-message [message]=\"message\" />\n } @else {\n {{ \"documentWorkspaces.detail.docsLoadError\" | transloco }}\n }\n </span>\n <button variant=\"secondary\" size=\"xs\" (click)=\"load()\">{{ \"documentWorkspaces.common.retry\" | transloco }}</button>\n </div>\n }\n @default {\n @if (workspace(); as workspace) {\n @if (!documents().length) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"empty\">\n <empty-folder-icon />\n <h5 class=\"text-sm font-semibold text-(--font-primary-dark)\">{{ \"documentWorkspaces.detail.emptyTitle\" | transloco }}</h5>\n <p class=\"max-w-70 text-sm text-(--font-primary-base)\">{{ \"documentWorkspaces.detail.emptySubtitle\" | transloco }}</p>\n </div>\n } @else if (!filtered().length) {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"no-match\">{{ \"documentWorkspaces.detail.noMatch\" | transloco }}</p>\n } @else if (viewMode() === \"grid\") {\n <div class=\"grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] content-start gap-4 pb-8\">\n @for (document of filtered(); track document.id) {\n <document-card\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n } @else {\n <div role=\"table\" class=\"flex flex-col gap-3 pb-8\">\n <div role=\"row\" [attr.data-selecting]=\"selection.active() ? '' : null\" class=\"grid grid-cols-[minmax(0,1fr)_auto_2.5rem] items-center gap-x-4 px-3 data-selecting:grid-cols-[2rem_minmax(0,1fr)_auto_2.5rem] @3xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] @3xl:data-selecting:grid-cols-[2rem_minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] py-1 text-xs font-semibold text-(--font-primary-muted)\">\n @if (selection.active()) {\n <div role=\"columnheader\">\n <input\n type=\"checkbox\"\n class=\"accent-(--bg-primary-base) size-4\"\n [checked]=\"selection.allSelected()\"\n [indeterminate]=\"selection.partiallySelected()\"\n [attr.aria-label]=\"'documentWorkspaces.common.selectAll' | transloco\"\n (change)=\"selection.toggleAll()\" />\n\n </div>\n }\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnName\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSource\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSize\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnDate\" | transloco }}</div>\n <div role=\"columnheader\"></div>\n </div>\n @for (document of filtered(); track document.id) {\n <document-row\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n }\n\n @if (hasMore()) {\n <div class=\"flex flex-col items-center gap-2\">\n @if (loadMoreError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.loadMoreError\" | transloco: { error: message } }}</p>\n }\n <button variant=\"secondary\" size=\"xs\" [disabled]=\"loadingMore()\" (click)=\"loadMore()\">\n {{ \"documentWorkspaces.detail.loadMore\" | transloco }}\n </button>\n </div>\n }\n } @else {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"not-found\">{{ \"documentWorkspaces.errors.notFound\" | transloco }}</p>\n }\n }\n }\n </div>\n </div>\n\n <!-- The list gives up part of its width to the preview, and takes it back when the preview closes. -->\n <div\n #handle=\"resizeHandle\"\n class=\"group/handle flex w-3 shrink-0 cursor-col-resize items-center justify-center max-md:hidden\"\n [class.hidden]=\"!previewed()\"\n [resizeHandle]=\"previewWidth()\"\n (resizeHandleChange)=\"userPreviewWidth.set($event)\"\n [resizeMin]=\"280\"\n [resizeMax]=\"maxPreviewWidth()\"\n [attr.aria-label]=\"'documentWorkspaces.preview.resize' | transloco\">\n <span class=\"h-6 w-0.5 rounded-full bg-(--stroke-primary-light) opacity-0 group-hover/handle:opacity-100 group-focus-visible/handle:opacity-100 group-data-resizing/handle:opacity-100\"></span>\n </div>\n <aside\n class=\"sticky top-3 my-3 mr-3 flex h-[calc(100dvh-1.5rem)] max-h-[calc(100%-1.5rem)] shrink-0 self-start overflow-hidden rounded-(--radius-xl) bg-(--bg-neutral-white) transition-[width] duration-[350ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none data-open:border data-open:border-(--stroke-primary-lighter) data-open:shadow-[0_4px_6px_-2px_rgba(45,57,58,0.05),0_10px_15px_-3px_rgba(45,57,58,0.1)] max-md:data-open:fixed max-md:data-open:inset-0 max-md:data-open:z-50 max-md:data-open:m-0 max-md:data-open:w-full! max-md:data-open:max-h-none max-md:data-open:rounded-none\"\n [class.transition-none]=\"handle.resizing()\"\n [style.width.px]=\"previewed() ? previewWidth() : 0\"\n [attr.data-open]=\"previewed() ? '' : null\"\n [attr.aria-hidden]=\"previewed() ? null : 'true'\"\n data-testid=\"preview\">\n @if (previewed(); as previewed) {\n <!-- Its own width, so the content does not reflow while the card opens. -->\n <document-preview-panel\n class=\"shrink-0 max-md:w-full!\"\n [style.width.px]=\"previewWidth()\"\n [document]=\"previewed\"\n [queryName]=\"queryName()\"\n (closed)=\"previewedDocumentId.set(null)\"\n (askFollowUp)=\"askFollowUp.emit($event)\" />\n }\n </aside>\n ", isInline: true, dependencies: [{ kind: "component", type: ChevronDownIcon, selector: "chevron-down, ChevronDown, chevrondown, chevron-down-icon, ChevronDownIcon, chevrondownicon", inputs: ["class"] }, { kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "component", type: DocumentCard, selector: "document-card", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: DocumentPreviewPanel, selector: "document-preview-panel", inputs: ["document", "queryName"], outputs: ["closed", "askFollowUp"] }, { kind: "component", type: DocumentRow, selector: "document-row", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: EmptyFolderIcon, selector: "empty-folder-icon" }, { kind: "component", type: GridIcon, selector: "grid-icon", inputs: ["class"] }, { kind: "component", type: ListIcon, selector: "list-icon, ListIcon", inputs: ["class"] }, { kind: "component", type: MagnifyingGlassIcon, selector: "magnifying-glass-icon, MagnifyingGlassIcon", inputs: ["class"] }, { kind: "component", type: PenToSquareIcon, selector: "pen-to-square-icon, PenToSquareIcon", inputs: ["class"] }, { kind: "component", type: RefreshIcon, selector: "refresh-icon, RefreshIcon, refreshicon", inputs: ["class"] }, { kind: "directive", type: ResizeHandle, selector: "[resizeHandle]", inputs: ["resizeHandle", "resizeMin", "resizeMax", "resizeStep", "resizeEdge"], outputs: ["resizeHandleChange"], exportAs: ["resizeHandle"] }, { kind: "component", type: TagIcon, selector: "tag-icon, TagIcon", inputs: ["class"] }, { kind: "directive", type: TooltipDirective, selector: "[tooltip]", inputs: ["tooltip", "strategy", "tooltip-position", "tooltip-delay", "tooltip-duration", "tooltip-offset"] }, { kind: "component", type: TrashIcon, selector: "trash-icon, TrashIcon", inputs: ["class"] }, { kind: "component", type: UploadIcon, selector: "upload-icon, UploadIcon, uploadicon", inputs: ["class"] }, { kind: "component", type: UserRoundIcon, selector: "user-round, UserRound, userround, user-round-icon, UserRoundIcon, userroundicon", inputs: ["class"] }, { kind: "component", type: WorkspaceMessage, selector: "workspace-message", inputs: ["message"] }, { kind: "directive", type: WriterOnly, selector: "[writerOnly]", inputs: ["writerOnly"] }, { kind: "component", type: XMarkIcon, selector: "XMarkIcon, xmark-icon, x-mark-icon", inputs: ["class"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6044
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.29", type: WorkspaceDetail, isStandalone: true, selector: "workspace-detail", inputs: { workspaceId: { classPropertyName: "workspaceId", publicName: "workspaceId", isSignal: true, isRequired: true, transformFunction: null }, uploadEndpoint: { classPropertyName: "uploadEndpoint", publicName: "uploadEndpoint", isSignal: true, isRequired: false, transformFunction: null }, previewedDocumentId: { classPropertyName: "previewedDocumentId", publicName: "previewedDocumentId", isSignal: true, isRequired: false, transformFunction: null }, queryName: { classPropertyName: "queryName", publicName: "queryName", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { previewedDocumentId: "previewedDocumentIdChange", back: "back", deleted: "deleted", workspaceUpdated: "workspaceUpdated", askFollowUp: "askFollowUp" }, host: { classAttribute: "flex h-full min-h-0" }, providers: [provideTranslocoScope("document-workspaces")], ngImport: i0, template: "\n <div class=\"@container flex min-h-0 min-w-0 flex-1 flex-col p-3 pb-2 md:p-8 md:pb-4\">\n <div class=\"mb-3 flex flex-wrap items-center gap-x-3 gap-y-2\">\n <button class=\"w-6\" size=\"sm\"\n variant=\"ghost\"\n [iconOnly]=\"true\"\n [attr.aria-label]=\"'documentWorkspaces.detail.back' | transloco\"\n [tooltip]=\"'documentWorkspaces.detail.back' | transloco\"\n (click)=\"leave()\">\n <chevron-down-icon class=\"rotate-90\" />\n </button>\n\n @if (workspace(); as workspace) {\n <h1 class=\"min-w-28 flex-[1_1_16rem] text-2xl leading-[31px] font-bold break-words text-(--font-neutral-black)\">{{ workspace.name }}</h1>\n\n <!-- Beside the title while they fit next to its 16rem, on a line of their own under it once they do not. -->\n @if (!deletedElsewhere()) {\n <div class=\"ml-auto flex shrink-0 items-center gap-1\">\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\"\n [writerOnly]=\"workspace.currentUserRole\"\n (click)=\"confirmDeleteWorkspace(workspace)\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"share(workspace)\">\n <user-round-icon />\n {{ \"documentWorkspaces.share.title\" | transloco }}\n </button>\n <button size=\"sm\" variant=\"ghost\" class=\"gap-1.5\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"edit(workspace)\">\n <pen-to-square-icon />\n {{ \"documentWorkspaces.common.edit\" | transloco }}\n </button>\n <button size=\"sm\"\n variant=\"ghost\"\n class=\"w-6\"\n [iconOnly]=\"true\"\n [disabled]=\"refreshing()\"\n [attr.aria-label]=\"'documentWorkspaces.common.refresh' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.refresh' | transloco\"\n (click)=\"refresh()\">\n <refresh-icon />\n </button>\n </div>\n }\n }\n </div>\n\n @if (workspace(); as workspace) {\n @if (workspace.description) {\n <p class=\"mb-3 text-base text-(--font-primary-base)\">{{ workspace.description }}</p>\n }\n @if (workspace.tags.length) {\n <p class=\"mb-6 flex items-center gap-2 text-sm text-(--font-primary-base)\" data-testid=\"tags\">\n <tag-icon class=\"size-4\" />\n {{ workspace.tags.join(\", \") }}\n </p>\n }\n\n @if (!deletedElsewhere()) {\n <div class=\"mb-3 flex flex-wrap items-center gap-2\">\n <div role=\"group\" class=\"inline-flex gap-1 rounded-full bg-(--bg-primary-lighter) p-0.5\">\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'grid'\"\n [attr.aria-label]=\"'documentWorkspaces.common.gridView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.gridView' | transloco\"\n (click)=\"viewMode.set('grid')\">\n <grid-icon class=\"size-3.5\" />\n </button>\n <button\n variant=\"none\"\n class=\"inline-flex size-6 items-center justify-center rounded-full text-(--font-primary-base) aria-pressed:bg-(--bg-neutral-white) aria-pressed:shadow-md\"\n [attr.aria-pressed]=\"viewMode() === 'list'\"\n [attr.aria-label]=\"'documentWorkspaces.common.listView' | transloco\"\n [tooltip]=\"'documentWorkspaces.common.listView' | transloco\"\n (click)=\"viewMode.set('list')\">\n <list-icon class=\"size-3.5\" />\n </button>\n </div>\n\n <div\n role=\"search\"\n class=\"flex h-9 min-w-32 flex-[0_1_15rem] items-center gap-2 rounded-full border border-(--stroke-primary-light) bg-(--bg-neutral-white) px-3 focus-within:border-(--stroke-primary-base)\">\n <magnifying-glass-icon class=\"size-4 shrink-0 text-(--font-primary-muted)\" />\n <input\n type=\"text\"\n class=\"min-w-0 flex-1 bg-transparent text-xs text-(--font-neutral-black) outline-none placeholder:text-(--font-primary-muted)\"\n [value]=\"query()\"\n [attr.placeholder]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n [attr.aria-label]=\"'documentWorkspaces.detail.searchPlaceholder' | transloco\"\n (input)=\"onSearch($event)\" />\n @if (query()) {\n <button variant=\"none\" type=\"button\" class=\"inline-flex\" [attr.aria-label]=\"'documentWorkspaces.common.clearSearch' | transloco\" (click)=\"query.set('')\">\n <x-mark-icon class=\"size-4\" />\n </button>\n }\n </div>\n\n <button class=\"gap-1.5\" size=\"sm\" variant=\"primary\" [writerOnly]=\"workspace.currentUserRole\" (click)=\"upload(workspace)\">\n <upload-icon />\n {{ \"documentWorkspaces.detail.upload\" | transloco }}\n </button>\n\n @if (selection.selectableItems().length) {\n @if (selection.active()) {\n <div class=\"ml-auto flex items-center gap-2 @max-xl:order-last @max-xl:ml-0 @max-xl:basis-full\" data-testid=\"selection-bar\">\n <button variant=\"ghost\" size=\"xs\" (click)=\"selection.toggleAll()\">\n {{ (selection.allSelected() ? \"documentWorkspaces.common.unselectAll\" : \"documentWorkspaces.common.selectAll\") | transloco }}\n </button>\n <button class=\"gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)\" variant=\"ghost\" size=\"xs\" [disabled]=\"!selection.count()\" (click)=\"deleteSelected()\">\n <trash-icon />\n {{ \"documentWorkspaces.common.delete\" | transloco }}\n </button>\n </div>\n }\n <button variant=\"ghost\" size=\"xs\" class=\"@max-xl:ml-auto\" [class.ml-auto]=\"!selection.active()\" (click)=\"selection.toggleMode()\">\n {{ (selection.active() ? \"documentWorkspaces.common.cancel\" : \"documentWorkspaces.common.select\") | transloco }}\n </button>\n }\n </div>\n\n <p class=\"mb-6 text-sm text-(--font-primary-base)\" aria-live=\"polite\" data-testid=\"count\">\n @if (selection.active()) {\n {{ \"documentWorkspaces.detail.selectedDocumentCount\" | transloco: { count: selection.count() } }}\n } @else {\n {{ \"documentWorkspaces.detail.fileCount\" | transloco: { count: workspace.documentCount } }}\n }\n </p>\n }\n }\n\n @if (refreshError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.refreshError\" | transloco: { error: message } }}</p>\n }\n\n <!-- Only the documents scroll: the title, the toolbar and the count stay in place. -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n @switch (status()) {\n @case (\"loading\") {\n <p class=\"text-(--font-primary-muted) text-sm\">{{ \"documentWorkspaces.detail.docsLoading\" | transloco }}</p>\n }\n @case (\"error\") {\n <div class=\"flex items-center gap-3 text-sm\" data-testid=\"load-error\">\n <span class=\"text-(--font-error-base)\">\n @if (loadError(); as message) {\n <workspace-message [message]=\"message\" />\n } @else {\n {{ \"documentWorkspaces.detail.docsLoadError\" | transloco }}\n }\n </span>\n <button variant=\"secondary\" size=\"xs\" (click)=\"load()\">{{ \"documentWorkspaces.common.retry\" | transloco }}</button>\n </div>\n }\n @default {\n @if (deletedElsewhere()) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"not-found\">\n <not-found-icon />\n <p class=\"max-w-70 text-sm text-(--font-primary-base)\">{{ \"documentWorkspaces.errors.notFound\" | transloco }}</p>\n <button variant=\"secondary\" size=\"sm\" class=\"mt-2\" (click)=\"leave()\">{{ \"documentWorkspaces.detail.back\" | transloco }}</button>\n </div>\n } @else if (workspace(); as workspace) {\n @if (!documents().length) {\n <div class=\"flex flex-col items-center gap-1 py-8 text-center\" data-testid=\"empty\">\n <empty-folder-icon />\n <h5 class=\"text-sm font-semibold text-(--font-primary-dark)\">{{ \"documentWorkspaces.detail.emptyTitle\" | transloco }}</h5>\n <p class=\"max-w-70 text-sm text-(--font-primary-base)\">{{ \"documentWorkspaces.detail.emptySubtitle\" | transloco }}</p>\n </div>\n } @else if (!filtered().length) {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"no-match\">{{ \"documentWorkspaces.detail.noMatch\" | transloco }}</p>\n } @else if (viewMode() === \"grid\") {\n <div class=\"grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] content-start gap-4 pb-8\">\n @for (document of filtered(); track document.id) {\n <document-card\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n } @else {\n <div role=\"table\" class=\"flex flex-col gap-3 pb-8\">\n <div role=\"row\" [attr.data-selecting]=\"selection.active() ? '' : null\" class=\"grid grid-cols-[minmax(0,1fr)_auto_2.5rem] items-center gap-x-4 px-3 data-selecting:grid-cols-[2rem_minmax(0,1fr)_auto_2.5rem] @3xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] @3xl:data-selecting:grid-cols-[2rem_minmax(0,2fr)_minmax(0,1fr)_6rem_10rem_2.5rem] py-1 text-xs font-semibold text-(--font-primary-muted)\">\n @if (selection.active()) {\n <div role=\"columnheader\">\n <input\n type=\"checkbox\"\n class=\"accent-(--bg-primary-base) size-4\"\n [checked]=\"selection.allSelected()\"\n [indeterminate]=\"selection.partiallySelected()\"\n [attr.aria-label]=\"'documentWorkspaces.common.selectAll' | transloco\"\n (change)=\"selection.toggleAll()\" />\n\n </div>\n }\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnName\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSource\" | transloco }}</div>\n <div role=\"columnheader\" class=\"@max-3xl:hidden\">{{ \"documentWorkspaces.detail.columnSize\" | transloco }}</div>\n <div role=\"columnheader\">{{ \"documentWorkspaces.detail.columnDate\" | transloco }}</div>\n <div role=\"columnheader\"></div>\n </div>\n @for (document of filtered(); track document.id) {\n <document-row\n [document]=\"document\"\n [workspaceRole]=\"workspace.currentUserRole\"\n [query]=\"query()\"\n [active]=\"document.id === previewedDocumentId()\"\n [selecting]=\"selection.active()\"\n [selected]=\"selection.isSelected(document)\"\n [selectable]=\"selection.canSelect(document)\"\n (opened)=\"togglePreview(document)\"\n (deleteRequested)=\"confirmDeleteDocuments([document])\"\n (selectedChange)=\"selection.toggle(document, $event)\" />\n }\n </div>\n }\n\n @if (hasMore()) {\n <div class=\"flex flex-col items-center gap-2\">\n @if (loadMoreError(); as message) {\n <p role=\"alert\" class=\"text-(--font-error-base) text-sm\">{{ \"documentWorkspaces.detail.loadMoreError\" | transloco: { error: message } }}</p>\n }\n <button variant=\"secondary\" size=\"xs\" [disabled]=\"loadingMore()\" (click)=\"loadMore()\">\n {{ \"documentWorkspaces.detail.loadMore\" | transloco }}\n </button>\n </div>\n }\n } @else {\n <p class=\"text-(--font-primary-muted) py-8 text-center text-sm\" data-testid=\"not-found\">{{ \"documentWorkspaces.errors.notFound\" | transloco }}</p>\n }\n }\n }\n </div>\n </div>\n\n <!-- The list gives up part of its width to the preview, and takes it back when the preview closes. -->\n <div\n #handle=\"resizeHandle\"\n class=\"group/handle flex w-3 shrink-0 cursor-col-resize items-center justify-center max-md:hidden\"\n [class.hidden]=\"!previewed()\"\n [resizeHandle]=\"previewWidth()\"\n (resizeHandleChange)=\"userPreviewWidth.set($event)\"\n [resizeMin]=\"280\"\n [resizeMax]=\"maxPreviewWidth()\"\n [attr.aria-label]=\"'documentWorkspaces.preview.resize' | transloco\">\n <span class=\"h-6 w-0.5 rounded-full bg-(--stroke-primary-light) opacity-0 group-hover/handle:opacity-100 group-focus-visible/handle:opacity-100 group-data-resizing/handle:opacity-100\"></span>\n </div>\n <aside\n class=\"sticky top-3 my-3 mr-3 flex h-[calc(100dvh-1.5rem)] max-h-[calc(100%-1.5rem)] shrink-0 self-start overflow-hidden rounded-(--radius-xl) bg-(--bg-neutral-white) transition-[width] duration-[350ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none data-open:border data-open:border-(--stroke-primary-lighter) data-open:shadow-[0_4px_6px_-2px_rgba(45,57,58,0.05),0_10px_15px_-3px_rgba(45,57,58,0.1)] max-md:data-open:fixed max-md:data-open:inset-0 max-md:data-open:z-50 max-md:data-open:m-0 max-md:data-open:w-full! max-md:data-open:max-h-none max-md:data-open:rounded-none\"\n [class.transition-none]=\"handle.resizing()\"\n [style.width.px]=\"previewed() ? previewWidth() : 0\"\n [attr.data-open]=\"previewed() ? '' : null\"\n [attr.aria-hidden]=\"previewed() ? null : 'true'\"\n data-testid=\"preview\">\n @if (previewed(); as previewed) {\n <!-- Its own width, so the content does not reflow while the card opens. -->\n <document-preview-panel\n class=\"shrink-0 max-md:w-full!\"\n [style.width.px]=\"previewWidth()\"\n [document]=\"previewed\"\n [queryName]=\"queryName()\"\n (closed)=\"previewedDocumentId.set(null)\"\n (askFollowUp)=\"askFollowUp.emit($event)\" />\n }\n </aside>\n ", isInline: true, dependencies: [{ kind: "component", type: ChevronDownIcon, selector: "chevron-down, ChevronDown, chevrondown, chevron-down-icon, ChevronDownIcon, chevrondownicon", inputs: ["class"] }, { kind: "directive", type: ButtonComponent, selector: "button", inputs: ["class", "variant", "decoration", "scheme", "iconOnly", "size", "solid"] }, { kind: "component", type: DocumentCard, selector: "document-card", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: DocumentPreviewPanel, selector: "document-preview-panel", inputs: ["document", "queryName"], outputs: ["closed", "askFollowUp"] }, { kind: "component", type: DocumentRow, selector: "document-row", inputs: ["document", "workspaceRole", "query", "active", "selecting", "selected", "selectable"], outputs: ["opened", "deleteRequested", "selectedChange"] }, { kind: "component", type: EmptyFolderIcon, selector: "empty-folder-icon" }, { kind: "component", type: GridIcon, selector: "grid-icon", inputs: ["class"] }, { kind: "component", type: ListIcon, selector: "list-icon, ListIcon", inputs: ["class"] }, { kind: "component", type: MagnifyingGlassIcon, selector: "magnifying-glass-icon, MagnifyingGlassIcon", inputs: ["class"] }, { kind: "component", type: NotFoundIcon, selector: "not-found-icon" }, { kind: "component", type: PenToSquareIcon, selector: "pen-to-square-icon, PenToSquareIcon", inputs: ["class"] }, { kind: "component", type: RefreshIcon, selector: "refresh-icon, RefreshIcon, refreshicon", inputs: ["class"] }, { kind: "directive", type: ResizeHandle, selector: "[resizeHandle]", inputs: ["resizeHandle", "resizeMin", "resizeMax", "resizeStep", "resizeEdge"], outputs: ["resizeHandleChange"], exportAs: ["resizeHandle"] }, { kind: "component", type: TagIcon, selector: "tag-icon, TagIcon", inputs: ["class"] }, { kind: "directive", type: TooltipDirective, selector: "[tooltip]", inputs: ["tooltip", "strategy", "tooltip-position", "tooltip-delay", "tooltip-duration", "tooltip-offset"] }, { kind: "component", type: TrashIcon, selector: "trash-icon, TrashIcon", inputs: ["class"] }, { kind: "component", type: UploadIcon, selector: "upload-icon, UploadIcon, uploadicon", inputs: ["class"] }, { kind: "component", type: UserRoundIcon, selector: "user-round, UserRound, userround, user-round-icon, UserRoundIcon, userroundicon", inputs: ["class"] }, { kind: "component", type: WorkspaceMessage, selector: "workspace-message", inputs: ["message"] }, { kind: "directive", type: WriterOnly, selector: "[writerOnly]", inputs: ["writerOnly"] }, { kind: "component", type: XMarkIcon, selector: "XMarkIcon, xmark-icon, x-mark-icon", inputs: ["class"] }, { kind: "pipe", type: TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5850
6045
  }
5851
6046
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImport: i0, type: WorkspaceDetail, decorators: [{
5852
6047
  type: Component,
@@ -5863,6 +6058,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
5863
6058
  GridIcon,
5864
6059
  ListIcon,
5865
6060
  MagnifyingGlassIcon,
6061
+ NotFoundIcon,
5866
6062
  PenToSquareIcon,
5867
6063
  RefreshIcon,
5868
6064
  ResizeHandle,
@@ -5886,7 +6082,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
5886
6082
  [iconOnly]="true"
5887
6083
  [attr.aria-label]="'documentWorkspaces.detail.back' | transloco"
5888
6084
  [tooltip]="'documentWorkspaces.detail.back' | transloco"
5889
- (click)="back.emit()">
6085
+ (click)="leave()">
5890
6086
  <chevron-down-icon class="rotate-90" />
5891
6087
  </button>
5892
6088
 
@@ -5894,34 +6090,36 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
5894
6090
  <h1 class="min-w-28 flex-[1_1_16rem] text-2xl leading-[31px] font-bold break-words text-(--font-neutral-black)">{{ workspace.name }}</h1>
5895
6091
 
5896
6092
  <!-- Beside the title while they fit next to its 16rem, on a line of their own under it once they do not. -->
5897
- <div class="ml-auto flex shrink-0 items-center gap-1">
5898
- <button size="sm"
5899
- variant="ghost"
5900
- class="gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)"
5901
- [writerOnly]="workspace.currentUserRole"
5902
- (click)="confirmDeleteWorkspace(workspace)">
5903
- <trash-icon />
5904
- {{ "documentWorkspaces.common.delete" | transloco }}
5905
- </button>
5906
- <button size="sm" variant="ghost" class="gap-1.5" [writerOnly]="workspace.currentUserRole" (click)="share(workspace)">
5907
- <user-round-icon />
5908
- {{ "documentWorkspaces.share.title" | transloco }}
5909
- </button>
5910
- <button size="sm" variant="ghost" class="gap-1.5" [writerOnly]="workspace.currentUserRole" (click)="edit(workspace)">
5911
- <pen-to-square-icon />
5912
- {{ "documentWorkspaces.common.edit" | transloco }}
5913
- </button>
5914
- <button size="sm"
5915
- variant="ghost"
5916
- class="w-6"
5917
- [iconOnly]="true"
5918
- [disabled]="refreshing()"
5919
- [attr.aria-label]="'documentWorkspaces.common.refresh' | transloco"
5920
- [tooltip]="'documentWorkspaces.common.refresh' | transloco"
5921
- (click)="refresh()">
5922
- <refresh-icon />
5923
- </button>
5924
- </div>
6093
+ @if (!deletedElsewhere()) {
6094
+ <div class="ml-auto flex shrink-0 items-center gap-1">
6095
+ <button size="sm"
6096
+ variant="ghost"
6097
+ class="gap-1.5 text-(--font-error-base) hover:bg-(--bg-error-lightest)"
6098
+ [writerOnly]="workspace.currentUserRole"
6099
+ (click)="confirmDeleteWorkspace(workspace)">
6100
+ <trash-icon />
6101
+ {{ "documentWorkspaces.common.delete" | transloco }}
6102
+ </button>
6103
+ <button size="sm" variant="ghost" class="gap-1.5" [writerOnly]="workspace.currentUserRole" (click)="share(workspace)">
6104
+ <user-round-icon />
6105
+ {{ "documentWorkspaces.share.title" | transloco }}
6106
+ </button>
6107
+ <button size="sm" variant="ghost" class="gap-1.5" [writerOnly]="workspace.currentUserRole" (click)="edit(workspace)">
6108
+ <pen-to-square-icon />
6109
+ {{ "documentWorkspaces.common.edit" | transloco }}
6110
+ </button>
6111
+ <button size="sm"
6112
+ variant="ghost"
6113
+ class="w-6"
6114
+ [iconOnly]="true"
6115
+ [disabled]="refreshing()"
6116
+ [attr.aria-label]="'documentWorkspaces.common.refresh' | transloco"
6117
+ [tooltip]="'documentWorkspaces.common.refresh' | transloco"
6118
+ (click)="refresh()">
6119
+ <refresh-icon />
6120
+ </button>
6121
+ </div>
6122
+ }
5925
6123
  }
5926
6124
  </div>
5927
6125
 
@@ -5936,6 +6134,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
5936
6134
  </p>
5937
6135
  }
5938
6136
 
6137
+ @if (!deletedElsewhere()) {
5939
6138
  <div class="mb-3 flex flex-wrap items-center gap-2">
5940
6139
  <div role="group" class="inline-flex gap-1 rounded-full bg-(--bg-primary-lighter) p-0.5">
5941
6140
  <button
@@ -6006,6 +6205,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
6006
6205
  {{ "documentWorkspaces.detail.fileCount" | transloco: { count: workspace.documentCount } }}
6007
6206
  }
6008
6207
  </p>
6208
+ }
6009
6209
  }
6010
6210
 
6011
6211
  @if (refreshError(); as message) {
@@ -6031,7 +6231,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
6031
6231
  </div>
6032
6232
  }
6033
6233
  @default {
6034
- @if (workspace(); as workspace) {
6234
+ @if (deletedElsewhere()) {
6235
+ <div class="flex flex-col items-center gap-1 py-8 text-center" data-testid="not-found">
6236
+ <not-found-icon />
6237
+ <p class="max-w-70 text-sm text-(--font-primary-base)">{{ "documentWorkspaces.errors.notFound" | transloco }}</p>
6238
+ <button variant="secondary" size="sm" class="mt-2" (click)="leave()">{{ "documentWorkspaces.detail.back" | transloco }}</button>
6239
+ </div>
6240
+ } @else if (workspace(); as workspace) {
6035
6241
  @if (!documents().length) {
6036
6242
  <div class="flex flex-col items-center gap-1 py-8 text-center" data-testid="empty">
6037
6243
  <empty-folder-icon />
@@ -6937,6 +7143,10 @@ class UploadTrackerSection {
6937
7143
  const uploads = this.uploads();
6938
7144
  return uploads.length ? Math.round(uploads.reduce((sum, u) => sum + u.percent, 0) / uploads.length) : 0;
6939
7145
  }, ...(ngDevMode ? [{ debugName: "average" }] : []));
7146
+ /** The translation key standing for a row with no file name: a job started elsewhere. Only read when `name` is empty. */
7147
+ label(upload) {
7148
+ return upload.remote?.mine ? "documentWorkspaces.tracker.ownUpload" : "documentWorkspaces.tracker.remoteUpload";
7149
+ }
6940
7150
  round(value) {
6941
7151
  return Math.round(value);
6942
7152
  }
@@ -7013,8 +7223,8 @@ class UploadTrackerSection {
7013
7223
  <span
7014
7224
  class="min-w-0 flex-1 truncate text-xs text-(--font-neutral-black)"
7015
7225
  [class.text-(--font-error-base)]="upload.status === 'error' || upload.status === 'rejected'"
7016
- [attr.title]="upload.name"
7017
- >{{ upload.name }}</span
7226
+ [attr.title]="upload.name || (label(upload) | transloco)"
7227
+ >{{ upload.name || (label(upload) | transloco) }}</span
7018
7228
  >
7019
7229
  @if (upload.message) {
7020
7230
  <span class="max-w-40 shrink truncate text-[11px] text-(--font-neutral-muted)" [attr.title]="upload.message">{{ upload.message }}</span>
@@ -7023,9 +7233,12 @@ class UploadTrackerSection {
7023
7233
  @switch (upload.status) {
7024
7234
  @case ("uploading") {
7025
7235
  <span class="shrink-0 text-xs text-(--font-neutral-muted) tabular-nums">{{ round(upload.percent) }}%</span>
7026
- <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7027
- <x-mark-icon />
7028
- </button>
7236
+ <!-- A transfer this client does not hold cannot be stopped from here, and nor can it be closed while it goes on. -->
7237
+ @if (!upload.remote) {
7238
+ <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7239
+ <x-mark-icon />
7240
+ </button>
7241
+ }
7029
7242
  }
7030
7243
  @case ("indexing") {
7031
7244
  <!-- No percentage here: the server only reports one per job, shown by the bar above. -->
@@ -7155,8 +7368,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
7155
7368
  <span
7156
7369
  class="min-w-0 flex-1 truncate text-xs text-(--font-neutral-black)"
7157
7370
  [class.text-(--font-error-base)]="upload.status === 'error' || upload.status === 'rejected'"
7158
- [attr.title]="upload.name"
7159
- >{{ upload.name }}</span
7371
+ [attr.title]="upload.name || (label(upload) | transloco)"
7372
+ >{{ upload.name || (label(upload) | transloco) }}</span
7160
7373
  >
7161
7374
  @if (upload.message) {
7162
7375
  <span class="max-w-40 shrink truncate text-[11px] text-(--font-neutral-muted)" [attr.title]="upload.message">{{ upload.message }}</span>
@@ -7165,9 +7378,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
7165
7378
  @switch (upload.status) {
7166
7379
  @case ("uploading") {
7167
7380
  <span class="shrink-0 text-xs text-(--font-neutral-muted) tabular-nums">{{ round(upload.percent) }}%</span>
7168
- <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7169
- <x-mark-icon />
7170
- </button>
7381
+ <!-- A transfer this client does not hold cannot be stopped from here, and nor can it be closed while it goes on. -->
7382
+ @if (!upload.remote) {
7383
+ <button class="w-6" variant="ghost" size="xs" [iconOnly]="true" [attr.aria-label]="'documentWorkspaces.tracker.cancelUpload' | transloco" [tooltip]="'documentWorkspaces.tracker.cancelUpload' | transloco" (click)="discard.emit(upload.id)">
7384
+ <x-mark-icon />
7385
+ </button>
7386
+ }
7171
7387
  }
7172
7388
  @case ("indexing") {
7173
7389
  <!-- No percentage here: the server only reports one per job, shown by the bar above. -->
@@ -7208,7 +7424,9 @@ const SECTION_ORDER = ["uploading", "indexing", "done", "error", "rejected"];
7208
7424
  const FLASH_MS = 3000;
7209
7425
  const PEEK_MS = 3500;
7210
7426
  const WORKSPACE_COMPLETE_MS = 3500;
7211
- const AUTO_DISMISS_MS = 3000;
7427
+ // How long a panel whose uploads are all over stays: long enough to read the outcome, even of an
7428
+ // upload someone else started and the user was not watching.
7429
+ const AUTO_DISMISS_MS = FINISHED_UPLOAD_VISIBLE_MS;
7212
7430
  const DISMISS_ANIMATION_MS = 320;
7213
7431
  const HOVER_LEAVE_MS = 250;
7214
7432
  // The text on the strip leaves, then the new one comes: half a transition each.
@@ -7295,7 +7513,7 @@ class UploadTracker {
7295
7513
  const failed = this.store.failed();
7296
7514
  const severalWorkspaces = this.store.workspaces().length > 1;
7297
7515
  // One file is named, several are counted.
7298
- const label = (uploads, key, max) => uploads.length === 1 ? { text: truncateName(uploads[0].name, max) } : trackerMessage(key, { count: uploads.length });
7516
+ const label = (uploads, key, max) => uploads.length === 1 ? uploadLabel(uploads[0], max) : trackerMessage(key, { count: uploads.length });
7299
7517
  const chips = [];
7300
7518
  if (groups.uploading.length)
7301
7519
  chips.push({ kind: "uploading", busy: true, message: trackerMessage("uploadingCount", { count: groups.uploading.length }) });
@@ -7315,11 +7533,11 @@ class UploadTracker {
7315
7533
  return peek.message;
7316
7534
  const failed = this.store.failed();
7317
7535
  if (failed.length)
7318
- return failed.length === 1 ? { text: truncateName(failed[0].name, 22) } : trackerMessage("errorCount", { count: failed.length });
7536
+ return failed.length === 1 ? uploadLabel(failed[0], 22) : trackerMessage("errorCount", { count: failed.length });
7319
7537
  const { uploading, indexing, done } = this.store.byStatus();
7320
7538
  const active = uploading.length + indexing.length;
7321
7539
  if (done.length && !active)
7322
- return done.length === 1 ? { text: truncateName(done[0].name, 22) } : trackerMessage("indexedCount", { count: done.length });
7540
+ return done.length === 1 ? uploadLabel(done[0], 22) : trackerMessage("indexedCount", { count: done.length });
7323
7541
  return trackerMessage("fileCount", { count: active });
7324
7542
  }, ...(ngDevMode ? [{ debugName: "pillMessage" }] : []));
7325
7543
  state = computed(() => (this.mode() !== "minimized" ? "normal" : this.hover() ? "minimized-hover" : "minimized"), ...(ngDevMode ? [{ debugName: "state" }] : []));
@@ -7349,10 +7567,12 @@ class UploadTracker {
7349
7567
  this.completeWorkspace(complete);
7350
7568
  });
7351
7569
  });
7352
- // Everything indexed: hold the success message for a moment, then go away.
7570
+ // Everything is over: hold the outcome for a while, then go away — unless a failure of the
7571
+ // user's own is there to be read, which stays until they close it. A new upload cancels
7572
+ // this (nothing is settled any more), and the delay starts afresh when it ends.
7353
7573
  effect(() => {
7354
- const allDone = this.store.allDone();
7355
- untracked(() => (allDone ? this.startAutoDismiss() : this.cancelAutoDismiss()));
7574
+ const leaves = this.store.settled() && !this.store.authorFailed();
7575
+ untracked(() => (leaves ? this.startAutoDismiss() : this.cancelAutoDismiss()));
7356
7576
  });
7357
7577
  // The strip of a minimized panel says what is going on; when that changes, the text is swapped, not cut.
7358
7578
  effect(() => {
@@ -7413,9 +7633,9 @@ class UploadTracker {
7413
7633
  }
7414
7634
  this.mode.update(mode => (mode === "expanded" ? "inline" : "expanded"));
7415
7635
  }
7416
- /** Minimizes while there is work going on; once everything is over, closes for good. */
7636
+ /** Minimizes while there is work going on; once everything is over (failures included), closes for good. */
7417
7637
  dismiss() {
7418
- if (this.store.allDone() || this.mode() === "minimized") {
7638
+ if (this.store.settled() || this.mode() === "minimized") {
7419
7639
  this.reset();
7420
7640
  }
7421
7641
  else {
@@ -7471,14 +7691,14 @@ class UploadTracker {
7471
7691
  const failed = finished.filter(isFailed);
7472
7692
  if (done.length) {
7473
7693
  const last = done[done.length - 1];
7474
- const message = { text: truncateName(last.name, this.mode() === "minimized" ? 22 : 30) };
7694
+ const message = uploadLabel(last, this.mode() === "minimized" ? 22 : 30);
7475
7695
  if (this.mode() === "minimized")
7476
7696
  this.notify(message, "success");
7477
7697
  else if (this.mode() === "inline" && !this.store.allDone())
7478
7698
  this.showFlash(message);
7479
7699
  }
7480
7700
  if (failed.length && this.mode() === "minimized") {
7481
- this.notify({ text: truncateName(failed[failed.length - 1].name, 22) }, "error");
7701
+ this.notify(uploadLabel(failed[failed.length - 1], 22), "error");
7482
7702
  }
7483
7703
  }
7484
7704
  /**
@@ -7533,8 +7753,9 @@ class UploadTracker {
7533
7753
  startAutoDismiss() {
7534
7754
  if (this.dismissTimer)
7535
7755
  return;
7536
- // The success message is in the header: make sure it can be seen.
7537
- this.mode.set("inline");
7756
+ // The success message is in the header: make sure it can be seen. Failures are in the sections: leave those open.
7757
+ if (this.store.allDone())
7758
+ this.mode.set("inline");
7538
7759
  this.dismissTimer = this.replaceTimer(null, AUTO_DISMISS_MS, () => {
7539
7760
  this.dismissTimer = null;
7540
7761
  this.dismissing.set(true);
@@ -7670,8 +7891,8 @@ class UploadTracker {
7670
7891
  variant="ghost"
7671
7892
  size="xs"
7672
7893
  [iconOnly]="true"
7673
- [attr.aria-label]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7674
- [tooltip]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7894
+ [attr.aria-label]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7895
+ [tooltip]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7675
7896
  (click)="dismiss(); $event.stopPropagation()">
7676
7897
  <x-mark-icon />
7677
7898
  </button>
@@ -7876,8 +8097,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.29", ngImpo
7876
8097
  variant="ghost"
7877
8098
  size="xs"
7878
8099
  [iconOnly]="true"
7879
- [attr.aria-label]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7880
- [tooltip]="(mode() === 'minimized' || store.allDone() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
8100
+ [attr.aria-label]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
8101
+ [tooltip]="(mode() === 'minimized' || store.settled() ? 'documentWorkspaces.common.close' : 'documentWorkspaces.common.minimize') | transloco"
7881
8102
  (click)="dismiss(); $event.stopPropagation()">
7882
8103
  <x-mark-icon />
7883
8104
  </button>