@remit/ui 0.0.13 → 0.0.15

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/button.render.test.ts +29 -0
  3. package/src/components/button.tsx +5 -1
  4. package/src/components/folder-role.test.ts +72 -0
  5. package/src/components/folder-role.tsx +59 -0
  6. package/src/components/mobile-search-view.render.test.ts +78 -0
  7. package/src/components/mobile-search-view.stories.tsx +117 -2
  8. package/src/components/mobile-search-view.tsx +20 -1
  9. package/src/components/primitives.stories.tsx +6 -0
  10. package/src/components/progress-bar.render.test.ts +46 -0
  11. package/src/components/progress-bar.stories.tsx +35 -0
  12. package/src/components/progress-bar.tsx +62 -0
  13. package/src/components/search-result-row.tsx +23 -0
  14. package/src/components/search-results.render.test.ts +181 -0
  15. package/src/components/search-results.stories.tsx +165 -28
  16. package/src/components/search-results.tsx +101 -5
  17. package/src/components/selection-top-bar.render.test.ts +167 -17
  18. package/src/components/selection-top-bar.stories.tsx +106 -16
  19. package/src/components/selection-top-bar.tsx +117 -64
  20. package/src/components/spam-results-offer.render.test.ts +26 -0
  21. package/src/components/spam-results-offer.stories.tsx +41 -0
  22. package/src/components/spam-results-offer.tsx +52 -0
  23. package/src/components/swipeable-row.render.test.ts +29 -0
  24. package/src/components/swipeable-row.tsx +42 -2
  25. package/src/components/touch-list-body.render.test.ts +25 -0
  26. package/src/components/touch-list-body.stories.tsx +15 -0
  27. package/src/components/touch-list.tsx +26 -15
  28. package/src/index.ts +15 -0
@@ -2,6 +2,7 @@ import { Flag } from "lucide-react";
2
2
  import type { ReactNode } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import { Badge } from "./badge.js";
5
+ import { provenanceFolderLabel, type ResultFolder } from "./folder-role.js";
5
6
 
6
7
  export type SearchResultTone =
7
8
  | "neutral"
@@ -27,6 +28,12 @@ export interface SearchResult {
27
28
  threadId?: string;
28
29
  /** The mailbox the result lives in; paired with {@link threadId} to open it. */
29
30
  mailboxId?: string;
31
+ /**
32
+ * The folder this row was read from. A search that reaches every folder
33
+ * returns rows from all over, so the row says where it came from; see
34
+ * {@link provenanceFolderLabel} for which folders can be named.
35
+ */
36
+ folder?: ResultFolder;
30
37
  /**
31
38
  * Why a semantic ("Related") hit matched — a plain-language label derived
32
39
  * from `matchedChunkType` (e.g. "body", "subject", "attachment"), so the user
@@ -43,6 +50,12 @@ export interface SearchResultRowProps {
43
50
  onClick?: () => void;
44
51
  /** When given, literal (case-insensitive) matches are bolded in subject/snippet. */
45
52
  query?: string;
53
+ /**
54
+ * Show the folder the row came from. Defaults to true. A search confined to
55
+ * one folder turns it off — every row would carry the same label, which is
56
+ * noise rather than provenance.
57
+ */
58
+ showFolder?: boolean;
46
59
  }
47
60
 
48
61
  function highlight(text: string, query?: string): ReactNode {
@@ -79,7 +92,12 @@ export function SearchResultRow({
79
92
  result,
80
93
  onClick,
81
94
  query,
95
+ showFolder = true,
82
96
  }: SearchResultRowProps) {
97
+ const folderLabel =
98
+ showFolder && result.folder
99
+ ? provenanceFolderLabel(result.folder)
100
+ : undefined;
83
101
  return (
84
102
  <button
85
103
  type="button"
@@ -118,6 +136,11 @@ export function SearchResultRow({
118
136
  <span className="min-w-0 flex-1 truncate text-xs text-fg-subtle">
119
137
  {highlight(result.snippet, query)}
120
138
  </span>
139
+ {folderLabel && (
140
+ <Badge tone="neutral" className="shrink-0">
141
+ {folderLabel}
142
+ </Badge>
143
+ )}
121
144
  {result.category && (
122
145
  <Badge tone={result.category.tone ?? "neutral"} className="shrink-0">
123
146
  {result.category.label}
@@ -87,3 +87,184 @@ describe("SearchResults", () => {
87
87
  assert.doesNotMatch(html, /Remove filter/);
88
88
  });
89
89
  });
90
+
91
+ const spamResult: SearchResult = {
92
+ id: "s1",
93
+ sender: "billing@unknown-vendor.test",
94
+ subject: "URGENT invoice attached",
95
+ snippet: "Wire the amount below.",
96
+ date: "Feb 11",
97
+ folder: { role: "junk" },
98
+ };
99
+
100
+ const archivedResult: SearchResult = {
101
+ ...result,
102
+ id: "a1",
103
+ sender: "Mollie",
104
+ folder: { role: "archive" },
105
+ };
106
+
107
+ const mixed: SearchResultSection[] = [
108
+ { id: "results", label: "Results", results: [archivedResult, spamResult] },
109
+ ];
110
+
111
+ describe("SearchResults spam handling", () => {
112
+ it("holds spam out of a global search and offers it as a count", () => {
113
+ const html = renderToString(
114
+ createElement(SearchResults, {
115
+ value: "invoice",
116
+ sections: mixed,
117
+ scope: { kind: "global" },
118
+ onScopeToSpam: noop,
119
+ }),
120
+ );
121
+ assert.doesNotMatch(html, /unknown-vendor/);
122
+ assert.match(html, /result from Spam/);
123
+ assert.match(html, /Mollie/);
124
+ });
125
+
126
+ it("makes no offer when a global search found nothing in spam", () => {
127
+ const html = renderToString(
128
+ createElement(SearchResults, {
129
+ value: "invoice",
130
+ sections: [
131
+ { id: "results", label: "Results", results: [archivedResult] },
132
+ ],
133
+ scope: { kind: "global" },
134
+ onScopeToSpam: noop,
135
+ }),
136
+ );
137
+ assert.doesNotMatch(html, /from Spam/);
138
+ });
139
+
140
+ it("offers spam above the empty state when every match is spam", () => {
141
+ const html = renderToString(
142
+ createElement(SearchResults, {
143
+ value: "invoice",
144
+ sections: [{ id: "results", label: "Results", results: [spamResult] }],
145
+ scope: { kind: "global" },
146
+ onScopeToSpam: noop,
147
+ }),
148
+ );
149
+ assert.match(html, /No matches for/);
150
+ assert.match(html, /result from Spam/);
151
+ });
152
+
153
+ it("shows neither spam rows nor an offer when scoped elsewhere", () => {
154
+ const html = renderToString(
155
+ createElement(SearchResults, {
156
+ value: "invoice",
157
+ sections: mixed,
158
+ scope: { kind: "folder", role: "inbox" },
159
+ onScopeToSpam: noop,
160
+ }),
161
+ );
162
+ assert.doesNotMatch(html, /unknown-vendor/);
163
+ assert.doesNotMatch(html, /from Spam/);
164
+ assert.match(html, /Mollie/);
165
+ });
166
+
167
+ it("renders spam rows normally and makes no offer when scoped to spam", () => {
168
+ const html = renderToString(
169
+ createElement(SearchResults, {
170
+ value: "invoice",
171
+ sections: mixed,
172
+ scope: { kind: "folder", role: "junk" },
173
+ onScopeToSpam: noop,
174
+ }),
175
+ );
176
+ assert.match(html, /unknown-vendor/);
177
+ assert.doesNotMatch(html, /from Spam/);
178
+ });
179
+
180
+ it("makes no offer without a way to scope to spam", () => {
181
+ const html = renderToString(
182
+ createElement(SearchResults, {
183
+ value: "invoice",
184
+ sections: mixed,
185
+ scope: { kind: "global" },
186
+ }),
187
+ );
188
+ assert.doesNotMatch(html, /from Spam/);
189
+ });
190
+
191
+ it("prefers a caller-supplied total over the rows held out of this page", () => {
192
+ const html = renderToString(
193
+ createElement(SearchResults, {
194
+ value: "invoice",
195
+ sections: mixed,
196
+ scope: { kind: "global" },
197
+ spamMatchCount: 42,
198
+ onScopeToSpam: noop,
199
+ }),
200
+ );
201
+ assert.match(html, />42</);
202
+ });
203
+ });
204
+
205
+ describe("SearchResults provenance labels", () => {
206
+ it("names the folder each row came from in a global search", () => {
207
+ const html = renderToString(
208
+ createElement(SearchResults, {
209
+ value: "invoice",
210
+ sections: [
211
+ {
212
+ id: "results",
213
+ label: "Results",
214
+ results: [
215
+ archivedResult,
216
+ {
217
+ ...result,
218
+ id: "c1",
219
+ folder: { providerPath: "Projects/Books" },
220
+ },
221
+ ],
222
+ },
223
+ ],
224
+ scope: { kind: "global" },
225
+ }),
226
+ );
227
+ assert.match(html, /Archive/);
228
+ assert.match(html, /Books/);
229
+ });
230
+
231
+ it("drops the labels when the search is scoped to one folder", () => {
232
+ const html = renderToString(
233
+ createElement(SearchResults, {
234
+ value: "invoice",
235
+ sections: [
236
+ { id: "results", label: "Results", results: [archivedResult] },
237
+ ],
238
+ scope: { kind: "folder", role: "archive" },
239
+ }),
240
+ );
241
+ assert.doesNotMatch(html, /Archive/);
242
+ });
243
+
244
+ it("leaves a row from a view rather than a folder unlabelled", () => {
245
+ const html = renderToString(
246
+ createElement(SearchResults, {
247
+ value: "invoice",
248
+ sections: [
249
+ {
250
+ id: "results",
251
+ label: "Results",
252
+ results: [
253
+ { ...result, id: "v1", folder: { role: "all" } },
254
+ { ...result, id: "v2", folder: { role: "flagged" } },
255
+ {
256
+ ...result,
257
+ id: "v3",
258
+ folder: { providerPath: "[Gmail]/Important" },
259
+ },
260
+ ],
261
+ },
262
+ ],
263
+ scope: { kind: "global" },
264
+ }),
265
+ );
266
+ assert.doesNotMatch(html, /All Mail/);
267
+ assert.doesNotMatch(html, /Starred/);
268
+ assert.doesNotMatch(html, /Important/);
269
+ });
270
+ });
@@ -73,43 +73,71 @@ const resultSections: SearchResultSection[] = [
73
73
  ];
74
74
 
75
75
  /**
76
- * Matches from outside the inbox — Archive, Sent, Spam and a custom folder.
77
- * These are the rows an unscoped search returns that an INBOX-only one could
78
- * not, so they only ever appear under the brief's sections.
76
+ * Matches from outside the inbox — Archive, Sent and a custom folder — each
77
+ * carrying the folder it was read from. These are the rows a search reaching
78
+ * every folder returns that an INBOX-only one could not.
79
79
  */
80
80
  const crossFolderMatches: SearchResult[] = [
81
81
  {
82
82
  id: "x1",
83
83
  sender: "Mollie",
84
84
  subject: "Invoice 2026-02 — archived",
85
- snippet: "Filed to Archive last month; payment already settled.",
85
+ snippet: "Filed last month; payment already settled.",
86
86
  date: "Feb 24",
87
+ folder: { role: "archive" },
87
88
  category: { label: "Receipt", tone: "positive" },
88
89
  },
89
90
  {
90
91
  id: "x2",
91
92
  sender: "me",
92
93
  subject: "Re: invoice query",
93
- snippet: "Sent — attaching the invoice you asked for.",
94
+ snippet: "Attaching the invoice you asked for.",
94
95
  date: "Feb 18",
96
+ folder: { role: "sent" },
95
97
  },
96
98
  {
97
- id: "x3",
99
+ id: "x4",
100
+ sender: "Accountant",
101
+ subject: "Invoices for the quarter",
102
+ snippet: "The quarterly set, filed with the rest of the bookkeeping.",
103
+ date: "Jan 30",
104
+ folder: { providerPath: "Projects/Bookkeeping" },
105
+ },
106
+ ];
107
+
108
+ /**
109
+ * Matches that live in the account's `\Junk` folder. A global search holds
110
+ * these out of the sections entirely and offers them as a count instead.
111
+ */
112
+ const spamMatches: SearchResult[] = [
113
+ {
114
+ id: "s1",
98
115
  sender: "billing@unknown-vendor.test",
99
116
  subject: "URGENT invoice attached",
100
- snippet: "Marked as spam, but it is the invoice the user is looking for.",
117
+ snippet: "Wire the amount below within 24 hours to avoid suspension.",
101
118
  date: "Feb 11",
102
- category: { label: "Spam", tone: "warning" },
119
+ folder: { role: "junk" },
103
120
  },
104
121
  {
105
- id: "x4",
106
- sender: "Accountant",
107
- subject: "Invoices for the quarter",
108
- snippet: "Filed under Projects/Bookkeeping.",
109
- date: "Jan 30",
122
+ id: "s2",
123
+ sender: "invoices@pay-now.test",
124
+ subject: "Outstanding invoice final notice",
125
+ snippet: "Your account is overdue. Settle immediately.",
126
+ date: "Feb 4",
127
+ folder: { role: "junk" },
110
128
  },
111
129
  ];
112
130
 
131
+ /** The literal section a global search returns: inbox rows plus everywhere else. */
132
+ const globalTopMatches: SearchResult[] = [
133
+ ...topMatches.map((result) => ({
134
+ ...result,
135
+ folder: { role: "inbox" as const },
136
+ })),
137
+ ...crossFolderMatches,
138
+ ...spamMatches,
139
+ ];
140
+
113
141
  const emptySections: SearchResultSection[] = [
114
142
  { id: "top", label: "Top matches", results: [] },
115
143
  ];
@@ -151,23 +179,45 @@ export const Idle: Story = {
151
179
 
152
180
  /**
153
181
  * The daily brief's unscoped search: no scope chip in the bar, and the literal
154
- * section carries matches from every folder — Archive, Sent, Spam and custom
155
- * folders alongside the inbox. Before the listing behind it took a search mode
156
- * it could only ever return inbox mail, so this section was silently narrower
157
- * than the bar promised.
182
+ * section carries matches from every folder — Archive, Sent and custom folders
183
+ * alongside the inbox. Each row says which folder it came from, because with
184
+ * nothing scoping the search the folder is the only thing placing the result.
158
185
  *
159
- * The rows themselves do not say which folder they came from; the sections are
160
- * ordered newest first regardless of where each message is filed.
186
+ * The two spam matches in the same data are not in this list. They are held out
187
+ * and offered above it as a count.
161
188
  */
162
- export const UnscopedAcrossFolders: Story = {
189
+ export const GlobalAcrossFolders: Story = {
163
190
  render: () => (
164
191
  <Harness
165
192
  value="invoice"
193
+ scope={{ kind: "global" }}
194
+ onScopeToSpam={() => {}}
195
+ sections={[
196
+ { id: "top", label: "Top matches", results: globalTopMatches },
197
+ { id: "related", label: "Related", results: related },
198
+ ]}
199
+ />
200
+ ),
201
+ };
202
+
203
+ /**
204
+ * The same global search over an account whose Spam folder holds nothing
205
+ * matching. No spam rows to hold out, so no offer — the offer only ever appears
206
+ * because there is something behind it.
207
+ */
208
+ export const GlobalWithoutSpamMatches: Story = {
209
+ render: () => (
210
+ <Harness
211
+ value="invoice"
212
+ scope={{ kind: "global" }}
213
+ onScopeToSpam={() => {}}
166
214
  sections={[
167
215
  {
168
216
  id: "top",
169
217
  label: "Top matches",
170
- results: [...topMatches, ...crossFolderMatches],
218
+ results: globalTopMatches.filter(
219
+ (result) => result.folder?.role !== "junk",
220
+ ),
171
221
  },
172
222
  { id: "related", label: "Related", results: related },
173
223
  ]}
@@ -176,18 +226,105 @@ export const UnscopedAcrossFolders: Story = {
176
226
  };
177
227
 
178
228
  /**
179
- * A scoped view (a mailbox route, its `in:` chip in the bar). Both sections are
180
- * scoped to that folder and take the kit's default labels the semantic
181
- * section used to run unscoped here under an "Everywhere" heading, which
182
- * contradicted the chip the same bar was showing.
229
+ * An account with no junk folder at all. Nothing is appointed `\Junk`, so no
230
+ * row can be spam and the component behaves exactly as it does when Spam is
231
+ * simply empty there is no separate case to handle.
232
+ */
233
+ export const GlobalAccountWithoutSpamFolder: Story = {
234
+ render: () => (
235
+ <Harness
236
+ value="invoice"
237
+ scope={{ kind: "global" }}
238
+ onScopeToSpam={() => {}}
239
+ sections={[
240
+ { id: "top", label: "Top matches", results: crossFolderMatches },
241
+ ]}
242
+ />
243
+ ),
244
+ };
245
+
246
+ /**
247
+ * A global search whose only matches are in Spam. The sections are empty, so
248
+ * the empty state stands — with the offer above it, which is the whole reason
249
+ * the user is not left thinking the search found nothing.
250
+ */
251
+ export const GlobalOnlySpamMatches: Story = {
252
+ render: () => (
253
+ <Harness
254
+ value="invoice"
255
+ scope={{ kind: "global" }}
256
+ onScopeToSpam={() => {}}
257
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
258
+ />
259
+ ),
260
+ };
261
+
262
+ /**
263
+ * Scoped to the inbox (its `in:inbox` chip in the bar), given the very same
264
+ * rows as the global story — spam matches included. Nothing about Spam appears:
265
+ * no rows, no count, no offer. A scoped search shows its own scope and no more,
266
+ * and that asymmetry with the global view is deliberate.
267
+ *
268
+ * The rows also drop their folder labels here. Every row is in the scoped
269
+ * folder, so naming it on each one repeats the chip.
270
+ */
271
+ export const ScopedToInbox: Story = {
272
+ render: () => (
273
+ <Harness
274
+ value="invoice"
275
+ scope={{ kind: "folder", role: "inbox" }}
276
+ onScopeToSpam={() => {}}
277
+ sections={[
278
+ { id: "top", label: "Top matches", results: globalTopMatches },
279
+ { id: "related", label: "Related", results: related },
280
+ ]}
281
+ />
282
+ ),
283
+ };
284
+
285
+ /**
286
+ * Scoped to Spam — where taking the offer lands. Ordinary rows, rendered
287
+ * normally, and no offer, because the user is already here. This is the same
288
+ * scoped search reached by navigating to Spam with the query carried over; the
289
+ * offer is a shortcut into it, not a mode of its own.
290
+ */
291
+ export const ScopedToSpam: Story = {
292
+ render: () => (
293
+ <Harness
294
+ value="invoice"
295
+ scope={{ kind: "folder", role: "junk" }}
296
+ onScopeToSpam={() => {}}
297
+ sections={[{ id: "top", label: "Top matches", results: spamMatches }]}
298
+ />
299
+ ),
300
+ };
301
+
302
+ /**
303
+ * A folder a search result can be in but never labelled with. All Mail and
304
+ * Starred are views over mail filed elsewhere, and Gmail exposes them as
305
+ * ordinary folders, so a row read from one carries no provenance label rather
306
+ * than a misleading one. The other rows keep theirs.
183
307
  */
184
- export const ScopedToOneFolder: Story = {
308
+ export const VirtualFoldersGoUnlabelled: Story = {
185
309
  render: () => (
186
310
  <Harness
187
311
  value="invoice"
312
+ scope={{ kind: "global" }}
188
313
  sections={[
189
- { id: "top", label: "Top matches", results: topMatches.slice(0, 2) },
190
- { id: "related", label: "Related", results: related.slice(0, 1) },
314
+ {
315
+ id: "top",
316
+ label: "Top matches",
317
+ results: [
318
+ { ...topMatches[0], id: "v1", folder: { role: "all" } },
319
+ { ...topMatches[1], id: "v2", folder: { role: "flagged" } },
320
+ {
321
+ ...topMatches[2],
322
+ id: "v3",
323
+ folder: { providerPath: "[Gmail]/Important" },
324
+ },
325
+ ...crossFolderMatches.slice(0, 1),
326
+ ],
327
+ },
191
328
  ]}
192
329
  />
193
330
  ),
@@ -1,8 +1,10 @@
1
1
  import { ChevronDown, Clock } from "lucide-react";
2
2
  import { useState } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
+ import type { FolderRole } from "./folder-role.js";
4
5
  import { type SearchResult, SearchResultRow } from "./search-result-row.js";
5
6
  import { SearchTokenChips } from "./search-token-chip.js";
7
+ import { SpamResultsOffer } from "./spam-results-offer.js";
6
8
 
7
9
  /** Rows shown before the "Show N more" expander kicks in. */
8
10
  const SECTION_ROW_CAP = 6;
@@ -15,6 +17,44 @@ export interface SearchResultSection {
15
17
  initialCollapsed?: boolean;
16
18
  }
17
19
 
20
+ /**
21
+ * What the search currently covers. `global` is the unscoped search the daily
22
+ * brief runs — every account, every folder, no chip in the bar. `folder` is a
23
+ * search the sidebar narrowed to one place, which the bar shows as a chip.
24
+ */
25
+ export type SearchScope =
26
+ | { kind: "global" }
27
+ | { kind: "folder"; role?: FolderRole };
28
+
29
+ const GLOBAL_SCOPE: SearchScope = { kind: "global" };
30
+
31
+ const isSpamScope = (scope: SearchScope): boolean =>
32
+ scope.kind === "folder" && scope.role === "junk";
33
+
34
+ const isSpamResult = (result: SearchResult): boolean =>
35
+ result.folder?.role === "junk";
36
+
37
+ /**
38
+ * Split spam matches out of the rows a search returned.
39
+ *
40
+ * Spam is identified by the account's `\Junk` special-use appointment, never by
41
+ * folder name, so an account that calls it `Junk`, `Bulk Mail` or nothing at
42
+ * all behaves the same, and an account with no junk folder simply never yields
43
+ * a spam row.
44
+ */
45
+ export function partitionSpamResults(results: SearchResult[]): {
46
+ kept: SearchResult[];
47
+ spam: SearchResult[];
48
+ } {
49
+ const kept: SearchResult[] = [];
50
+ const spam: SearchResult[] = [];
51
+ for (const result of results) {
52
+ if (isSpamResult(result)) spam.push(result);
53
+ else kept.push(result);
54
+ }
55
+ return { kept, spam };
56
+ }
57
+
18
58
  export interface SearchResultsProps {
19
59
  /** The current query — narrows what's shown and bolds literal matches. */
20
60
  value: string;
@@ -32,6 +72,19 @@ export interface SearchResultsProps {
32
72
  * query has no recognized tokens.
33
73
  */
34
74
  tokens?: { label: string; onRemove: () => void }[];
75
+ /** What the search covers. Defaults to the unscoped, global search. */
76
+ scope?: SearchScope;
77
+ /**
78
+ * Total spam matches the search found, for when the app knows more than the
79
+ * rows on this page. Defaults to the number of spam rows held out of
80
+ * {@link sections}.
81
+ */
82
+ spamMatchCount?: number;
83
+ /**
84
+ * Scope the search to Spam. Without it there is no offer, so an app that has
85
+ * nowhere to navigate simply never makes one.
86
+ */
87
+ onScopeToSpam?: () => void;
35
88
  }
36
89
 
37
90
  /**
@@ -44,10 +97,12 @@ function CollapsibleResultSection({
44
97
  section,
45
98
  query,
46
99
  onSelectResult,
100
+ showFolder,
47
101
  }: {
48
102
  section: SearchResultSection;
49
103
  query?: string;
50
104
  onSelectResult?: (result: SearchResult) => void;
105
+ showFolder?: boolean;
51
106
  }) {
52
107
  const [collapsed, setCollapsed] = useState(section.initialCollapsed ?? false);
53
108
  const [expanded, setExpanded] = useState(false);
@@ -87,6 +142,7 @@ function CollapsibleResultSection({
87
142
  key={result.id}
88
143
  result={result}
89
144
  query={query}
145
+ showFolder={showFolder}
90
146
  onClick={
91
147
  onSelectResult ? () => onSelectResult(result) : undefined
92
148
  }
@@ -115,6 +171,18 @@ function CollapsibleResultSection({
115
171
  * sections — one implementation so both tiers render identical rows. The caller
116
172
  * owns the surrounding chrome and scroll container (a `FilterSheet`, the
117
173
  * takeover header, or the list-pane body). Presentational and prop-driven.
174
+ *
175
+ * Spam is the one folder that does not inline, and it behaves differently by
176
+ * scope:
177
+ *
178
+ * - **Global** — spam rows are held out of the sections and offered instead, as
179
+ * a count with a way into a Spam-scoped search.
180
+ * - **Scoped to anything else** — spam rows are held out and nothing is offered.
181
+ * A scoped search shows its own scope and no more.
182
+ * - **Scoped to Spam** — ordinary rows, rendered normally, no offer.
183
+ *
184
+ * Provenance labels follow the same logic: only a global search names the
185
+ * folder each row came from, because a scoped one would repeat itself.
118
186
  */
119
187
  export function SearchResults({
120
188
  value,
@@ -124,11 +192,11 @@ export function SearchResults({
124
192
  loading,
125
193
  onSelectResult,
126
194
  tokens,
195
+ scope = GLOBAL_SCOPE,
196
+ spamMatchCount,
197
+ onScopeToSpam,
127
198
  }: SearchResultsProps) {
128
199
  const hasQuery = value.trim().length > 0;
129
- const hasResults = (sections ?? []).some(
130
- (section) => section.results.length > 0,
131
- );
132
200
 
133
201
  if (!hasQuery) {
134
202
  if (recentSearches && recentSearches.length > 0) {
@@ -181,10 +249,36 @@ export function SearchResults({
181
249
  );
182
250
  }
183
251
 
252
+ const spamScoped = isSpamScope(scope);
253
+ const isGlobal = scope.kind === "global";
254
+
255
+ const partitioned = (sections ?? []).map((section) => {
256
+ if (spamScoped) return { section, spam: [] as SearchResult[] };
257
+ const { kept, spam } = partitionSpamResults(section.results);
258
+ return { section: { ...section, results: kept }, spam };
259
+ });
260
+
261
+ const visibleSections = partitioned.map((entry) => entry.section);
262
+ const heldOutSpamCount = partitioned.reduce(
263
+ (total, entry) => total + entry.spam.length,
264
+ 0,
265
+ );
266
+ const spamCount = spamMatchCount ?? heldOutSpamCount;
267
+ const spamOffer = isGlobal &&
268
+ spamCount > 0 &&
269
+ onScopeToSpam !== undefined && (
270
+ <SpamResultsOffer count={spamCount} onScopeToSpam={onScopeToSpam} />
271
+ );
272
+
273
+ const hasResults = visibleSections.some(
274
+ (section) => section.results.length > 0,
275
+ );
276
+
184
277
  if (!hasResults) {
185
278
  return (
186
279
  <div className="flex flex-col">
187
280
  {chips}
281
+ {spamOffer}
188
282
  <div className="px-row-inset py-10 text-center">
189
283
  <p className="text-sm font-medium text-fg">
190
284
  No matches for &ldquo;{value}&rdquo;
@@ -200,13 +294,15 @@ export function SearchResults({
200
294
  return (
201
295
  <div className="flex flex-col">
202
296
  {chips}
203
- {sections
204
- ?.filter((section) => section.results.length > 0)
297
+ {spamOffer}
298
+ {visibleSections
299
+ .filter((section) => section.results.length > 0)
205
300
  .map((section) => (
206
301
  <CollapsibleResultSection
207
302
  key={section.id}
208
303
  section={section}
209
304
  query={value}
305
+ showFolder={isGlobal}
210
306
  onSelectResult={onSelectResult}
211
307
  />
212
308
  ))}