@graph8/sdk 0.373.0 → 0.374.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.
Files changed (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -15,13 +15,23 @@
15
15
  * renders its own ScreenLayout header ("Inbox" h1, stats). Opt out with `urlState={false}` (selection kept in component
16
16
  * state, the URL untouched) and/or `hideHeader` (no title or stats, just the toolbar and the two panes). Defaults keep the new look.
17
17
  *
18
+ * Management (all optional, on by default; `showManagement={false}` hides the three toolbar buttons):
19
+ * - "New message" (ComposeMessage): write a new email or text from a sender you choose (external, confirmed).
20
+ * - "Search all" (InboxSearch): search every email thread or LinkedIn conversation on the server, then open a result.
21
+ * - "Manage" (TagManager + BlockedSenders): create, rename, delete tags; block and unblock senders. The tag list it
22
+ * loads also feeds the tag filter and the tag picker.
23
+ * - In an open conversation, with `showThreadActions` (default off), "Manage conversation" (ThreadActions): mark read or unread, remove a tag, unassign, block sender.
24
+ * SDK operations of its own: none; the parts above own theirs and their confirm dialogs.
25
+ * A conversation this key cannot see (or that was removed) answers 404; the conversation view says so instead of failing.
26
+ *
18
27
  * Props are backwards compatible: `initialFilters`, `availableTags`, `className` as before; everything else is optional glue.
19
28
  */
20
29
  import { useMemo, useState, type ReactNode } from "react";
21
- import { Inbox as InboxIcon, Mail, RefreshCw } from "lucide-react";
30
+ import { Inbox as InboxIcon, Mail, PenSquare, RefreshCw, Search, Tags } from "lucide-react";
22
31
  import { Button, Skeleton } from "@graph8/sdk/ui";
23
32
  import { ApiError } from "@/g8/components/api-error";
24
33
  import { EmptyState } from "@/g8/components/empty-state";
34
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
25
35
  import { DetailPanel } from "@/g8/components/screen/detail-panel";
26
36
  import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
27
37
  import { KeyValueList } from "@/g8/components/screen/key-value-list";
@@ -42,7 +52,13 @@ import {
42
52
  type InboxTag,
43
53
  type InboxThread,
44
54
  } from "../lib/inbox-types";
55
+ import { isNotFound } from "../lib/inbox-manage";
56
+ import { BlockedSenders } from "./blocked-senders";
57
+ import { ComposeMessage } from "./compose-message";
58
+ import { InboxSearch } from "./inbox-search";
45
59
  import { ReplyComposer } from "./reply-composer";
60
+ import { TagManager } from "./tag-manager";
61
+ import { ThreadActions } from "./thread-actions";
46
62
  import { TagAndAssign } from "./tag-and-assign";
47
63
  import { ThreadView } from "./thread-view";
48
64
  import { useBrand } from "@/g8/hooks/use-brand";
@@ -66,6 +82,10 @@ export interface InboxScreenProps {
66
82
  urlState?: boolean;
67
83
  /** Skip the screen header (title, description, stats) when the host page already has one. Default false. */
68
84
  hideHeader?: boolean;
85
+ /** Show the New message, Search all and Manage buttons. Default true. */
86
+ showManagement?: boolean;
87
+ /** Show "Manage conversation" (mark read, remove tag, unassign, block sender) under the composer. Default false, so existing embedders see no new controls. */
88
+ showThreadActions?: boolean;
69
89
  }
70
90
 
71
91
  /** `useUrlState` or plain state, chosen once per mount (the flag must not change while mounted). */
@@ -160,6 +180,7 @@ function ListEmpty({ filtered, onClear }: { filtered: boolean; onClear: () => vo
160
180
 
161
181
  function ThreadDetails({ thread }: { thread: Selected }) {
162
182
  const { data: t, loading, error, refetch } = useInboxThread(thread.id, thread.channel);
183
+ if (isNotFound(error)) return <EmptyState compact title="Conversation not found" description="It may have been removed, or it is not shared with this API key." />;
163
184
  if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
164
185
  if (!t) return loading ? <Skeleton className="h-24 w-full" /> : null;
165
186
  const dash = "";
@@ -185,15 +206,18 @@ function ThreadDetails({ thread }: { thread: Selected }) {
185
206
 
186
207
  function ExtraTab({ thread, render }: { thread: Selected; render: (thread: InboxThread) => ReactNode }) {
187
208
  const { data, loading, error, refetch } = useInboxThread(thread.id, thread.channel);
209
+ if (isNotFound(error)) return <EmptyState compact title="Conversation not found" description="It may have been removed, or it is not shared with this API key." />;
188
210
  if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
189
211
  if (!data) return loading ? <Skeleton className="h-24 w-full" /> : null;
190
212
  return <>{render(data)}</>;
191
213
  }
192
214
 
193
- export function InboxScreen({ initialFilters, availableTags, className, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, pageSize = 25, urlState = true, hideHeader = false }: InboxScreenProps) {
215
+ export function InboxScreen({ initialFilters, availableTags, className, toolbarActions, renderRowActions, renderDetailActions, extraDetailTabs, pageSize = 25, urlState = true, hideHeader = false, showManagement = true, showThreadActions = false }: InboxScreenProps) {
194
216
  const [filters, setFilters] = useState<InboxFilters>(initialFilters ?? {});
195
217
  const [page, setPage] = useState(1);
196
218
  const [search, setSearch] = useState("");
219
+ const [sheet, setSheet] = useState<"compose" | "search" | "manage" | null>(null);
220
+ const [managed, setManaged] = useState<InboxTag[]>([]);
197
221
  const [openKey, setOpenKey] = useSelectionState("id", urlState);
198
222
  const [view, setView] = useSelectionState("view", urlState);
199
223
  const { threads, loading, error, hasNext, total, refetch } = useInboxThreads(filters, page, pageSize);
@@ -202,9 +226,9 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
202
226
 
203
227
  const tags = useMemo(() => {
204
228
  const seen = new Map<string, InboxTag>();
205
- for (const t of [...(availableTags ?? []), ...threads.flatMap((th) => th.tags)]) if (t.id) seen.set(t.id, t);
229
+ for (const t of [...(availableTags ?? []), ...managed, ...threads.flatMap((th) => th.tags)]) if (t.id) seen.set(t.id, t);
206
230
  return [...seen.values()];
207
- }, [availableTags, threads]);
231
+ }, [availableTags, managed, threads]);
208
232
  const needle = search.trim().toLowerCase();
209
233
  const rows = useMemo(
210
234
  () => (needle ? threads.filter((t) => `${contactLabel(t)} ${t.contact?.email ?? ""} ${t.contact?.company ?? ""} ${t.subject ?? ""} ${previewText(t, 300)}`.toLowerCase().includes(needle)) : threads),
@@ -222,6 +246,27 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
222
246
  const count = (c: string) => threads.filter((t) => t.channel === c).length;
223
247
  const first = open ? threads.find((t) => t.id === open.id && t.channel === open.channel) : undefined;
224
248
 
249
+ const management = showManagement ? (
250
+ <>
251
+ <Button size="sm" variant="outline" onClick={() => setSheet("search")}><Search className="mr-1 size-4" />Search all</Button>
252
+ <Button size="sm" variant="outline" onClick={() => setSheet("manage")}><Tags className="mr-1 size-4" />Manage</Button>
253
+ <Button size="sm" onClick={() => setSheet("compose")}><PenSquare className="mr-1 size-4" />New message</Button>
254
+ </>
255
+ ) : null;
256
+ const sheets = showManagement ? (
257
+ <>
258
+ <FormSheet open={sheet === "compose"} onOpenChange={(o) => !o && setSheet(null)} width="lg" title="New message" description="Write to someone new. Nothing is sent until you confirm.">
259
+ {sheet === "compose" ? <ComposeMessage onCancel={() => setSheet(null)} onSent={() => { setSheet(null); void refetch(); }} /> : null}
260
+ </FormSheet>
261
+ <FormSheet open={sheet === "search"} onOpenChange={(o) => !o && setSheet(null)} width="lg" title="Search all conversations" description="Searches your email and LinkedIn on the server, beyond the page loaded here.">
262
+ {sheet === "search" ? <InboxSearch onOpen={(hit) => { setOpenKey(`${hit.channel}:${hit.id}`); setView(null); setSheet(null); }} /> : null}
263
+ </FormSheet>
264
+ <FormSheet open={sheet === "manage"} onOpenChange={(o) => !o && setSheet(null)} width="lg" title="Manage the inbox" description="Tags and blocked senders.">
265
+ {sheet === "manage" ? <div className="space-y-6"><TagManager onTags={setManaged} /><BlockedSenders /></div> : null}
266
+ </FormSheet>
267
+ </>
268
+ ) : null;
269
+
225
270
  const panes = (
226
271
  <>
227
272
  <div className={expanded ? "grid gap-4" : "grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"}>
@@ -268,6 +313,7 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
268
313
  {renderDetailActions ? <div className="flex flex-wrap gap-2">{renderDetailActions(thread)}</div> : null}
269
314
  <ReplyComposer thread={thread} onSent={reload} />
270
315
  <TagAndAssign thread={thread} availableTags={tags} onChanged={reload} />
316
+ {showThreadActions ? <ThreadActions thread={thread} onChanged={reload} /> : null}
271
317
  </div>
272
318
  )}
273
319
  </ThreadView>
@@ -289,9 +335,10 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
289
335
  <div className={className ? `space-y-4 ${className}` : "space-y-4"}>
290
336
  <div className="flex flex-wrap items-center justify-between gap-2">
291
337
  <Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search this page" }} filters={<Filters filters={filters} tags={tags} update={update} />} />
292
- <div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{toolbarActions}</div>
338
+ <div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{management}{toolbarActions}</div>
293
339
  </div>
294
340
  {panes}
341
+ {sheets}
295
342
  </div>
296
343
  );
297
344
  }
@@ -301,7 +348,7 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
301
348
  title="Inbox"
302
349
  description="Every reply across email, SMS and LinkedIn in one queue."
303
350
  icon={<InboxIcon className="size-5" />}
304
- actions={<><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{toolbarActions}</>}
351
+ actions={<><Button size="sm" variant="outline" onClick={() => void refetch()}><RefreshCw className="mr-1 size-4" />Refresh</Button>{management}{toolbarActions}</>}
305
352
  stats={[
306
353
  { label: "Conversations", value: total != null ? total.toLocaleString() : threads.length, hint: total != null ? "in this view" : "on this page" },
307
354
  { label: "Drafts waiting", value: drafts, hint: "on this page", tone: drafts > 0 ? "warning" : "neutral" },
@@ -311,6 +358,7 @@ export function InboxScreen({ initialFilters, availableTags, className, toolbarA
311
358
  toolbar={<Toolbar search={{ value: search, onChange: setSearch, placeholder: "Search this page" }} filters={<Filters filters={filters} tags={tags} update={update} />} />}
312
359
  >
313
360
  {panes}
361
+ {sheets}
314
362
  </ScreenLayout>
315
363
  );
316
364
  }
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ /**
4
+ * InboxSearch: search ALL of your email threads or LinkedIn conversations on the server, not just the page the list
5
+ * has loaded. Type, press Enter (or Search), pick a result; `onOpen` receives the thread's channel and id so a screen
6
+ * can open it in its conversation view.
7
+ *
8
+ * SDK operations, in order:
9
+ * Email: 1. GET /inbox/mailboxes/paginated (aiInbox.listInboxMailboxesPaginated) READ - the mailboxes searched
10
+ * 2. POST /inbox/emails/search?page&page_size (inbox.searchInboxEmails) READ - `mailboxes` is required
11
+ * (an empty mailbox list is "no result", never "all", so every mailbox you can see is sent)
12
+ * LinkedIn: POST /inbox/linkedin/conversations/search (aiInbox.searchInboxLinkedinConversations) READ
13
+ * SMS threads have no search route in the API; they are found in the list. Searching sends nothing and costs nothing.
14
+ * Result rows are untyped in the contract, so they are read defensively; opening one loads the real thread
15
+ * (ThreadView), and a thread the key may not see answers a uniform 404 which the thread view explains.
16
+ */
17
+ import { useState } from "react";
18
+ import { Search } from "lucide-react";
19
+ import { Button, Input, Skeleton } from "@graph8/sdk/ui";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { EmptyState } from "@/g8/components/empty-state";
22
+ import { g8Post } from "@/g8/lib/g8-client";
23
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
24
+ import { FirstNotice } from "@/g8/components/paging-notices";
25
+ import { useInboxMailboxes } from "../hooks/use-inbox-mailboxes";
26
+ import { formatWhen } from "../lib/inbox-types";
27
+ import { rowsFrom, searchHit } from "../lib/inbox-manage";
28
+ import type { SearchHit } from "../lib/inbox-manage";
29
+
30
+ export interface InboxSearchProps {
31
+ /** Called with the chosen result's channel and thread id. */
32
+ onOpen?: (hit: { channel: "email" | "linkedin"; id: string }) => void;
33
+ pageSize?: number;
34
+ className?: string;
35
+ }
36
+
37
+ type Channel = "email" | "linkedin";
38
+
39
+ export function InboxSearch({ onOpen, pageSize = 20, className }: InboxSearchProps) {
40
+ const [channel, setChannel] = useState<Channel>("email");
41
+ const [text, setText] = useState("");
42
+ const [submitted, setSubmitted] = useState<{ channel: Channel; q: string; page: number } | null>(null);
43
+ const mailboxes = useInboxMailboxes({ enabled: channel === "email" });
44
+ const results = useG8Query(
45
+ async (signal): Promise<{ hits: SearchHit[]; hasNext: boolean }> => {
46
+ const s = submitted!;
47
+ if (s.channel === "email") {
48
+ const env = await g8Post("/inbox/emails/search", { mailboxes: (mailboxes.data ?? []).map((m) => m.address), search: s.q, sort_by: "updated_at", sort_order: "desc" }, { query: { page: s.page, page_size: pageSize }, signal });
49
+ const hits = rowsFrom<Record<string, unknown>>(env, "emails").map((r) => searchHit("email", r)).filter((h): h is SearchHit => h != null);
50
+ return { hits, hasNext: Boolean((env.pagination as { has_next?: boolean } | undefined)?.has_next) || hits.length >= pageSize };
51
+ }
52
+ const env = await g8Post("/inbox/linkedin/conversations/search", { search: s.q, limit: pageSize, offset: (s.page - 1) * pageSize, sort_order: "desc" }, { signal });
53
+ const hits = rowsFrom<Record<string, unknown>>(env, "conversations").map((r) => searchHit("linkedin", r)).filter((h): h is SearchHit => h != null);
54
+ return { hits, hasNext: hits.length >= pageSize };
55
+ },
56
+ [submitted?.channel, submitted?.q, submitted?.page, submitted ? (mailboxes.data ?? []).length : 0],
57
+ { enabled: submitted != null && (submitted.channel !== "email" || mailboxes.data != null) },
58
+ );
59
+
60
+ const run = () => {
61
+ const q = text.trim();
62
+ if (q) setSubmitted({ channel, q, page: 1 });
63
+ };
64
+ const hits = results.data?.hits ?? [];
65
+
66
+ return (
67
+ <div className={`space-y-3 ${className ?? ""}`}>
68
+ <form className="flex flex-wrap items-center gap-2" role="search" onSubmit={(e) => { e.preventDefault(); run(); }}>
69
+ <div role="group" aria-label="Search in" className="flex gap-1">
70
+ <Button type="button" size="sm" variant={channel === "email" ? "default" : "outline"} onClick={() => { setChannel("email"); setSubmitted(null); }}>Email</Button>
71
+ <Button type="button" size="sm" variant={channel === "linkedin" ? "default" : "outline"} onClick={() => { setChannel("linkedin"); setSubmitted(null); }}>LinkedIn</Button>
72
+ </div>
73
+ <Input aria-label="Search all conversations" className="min-w-48 flex-1" placeholder={channel === "email" ? "Subject, sender or text" : "Name or message text"} value={text} onChange={(e) => setText(e.target.value)} />
74
+ <Button type="submit" size="sm" disabled={!text.trim()}><Search className="mr-1 size-4" aria-hidden />Search</Button>
75
+ </form>
76
+ {channel === "email" ? <FirstNotice shown={mailboxes.items.length} limit={0} force={mailboxes.truncated} noun="mailboxes" /> : null}
77
+ {mailboxes.error && channel === "email" ? <ApiError error={mailboxes.error} title="Could not load your mailboxes" onRetry={() => void mailboxes.refetch()} /> : null}
78
+ {channel === "email" && mailboxes.data && mailboxes.data.length === 0 ? <p className="text-sm text-muted-foreground">No mailbox is connected yet, so there is nothing to search.</p> : null}
79
+ {results.error ? <ApiError error={results.error} title="The search failed" onRetry={() => void results.refetch()} /> : null}
80
+ {submitted && results.loading && !results.data ? <div aria-busy="true" className="space-y-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div> : null}
81
+ {submitted && results.data && hits.length === 0 ? <EmptyState compact title="No conversations found" description={`Nothing matches "${submitted.q}".`} /> : null}
82
+ {hits.length > 0 ? (
83
+ <ul aria-label="Search results" className="divide-y rounded-md border border-border">
84
+ {hits.map((h) => (
85
+ <li key={`${h.channel}:${h.id}`}>
86
+ <button type="button" className="flex w-full flex-col gap-0.5 p-3 text-left hover:bg-muted/50" onClick={() => onOpen?.({ channel: h.channel, id: h.id })}>
87
+ <span className="flex items-center justify-between gap-2"><span className="truncate text-sm font-medium">{h.title}</span><span className="shrink-0 text-xs text-muted-foreground">{formatWhen(h.when)}</span></span>
88
+ {h.subtitle ? <span className="truncate text-xs text-muted-foreground">{h.subtitle}</span> : null}
89
+ {h.preview ? <span className="truncate text-xs">{h.preview}</span> : null}
90
+ </button>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ ) : null}
95
+ {submitted && results.data && (submitted.page > 1 || results.data.hasNext) ? (
96
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
97
+ <span>Page {submitted.page}</span>
98
+ <div className="flex gap-2">
99
+ <Button size="sm" variant="outline" disabled={submitted.page <= 1 || results.loading} onClick={() => setSubmitted({ ...submitted, page: submitted.page - 1 })}>Previous</Button>
100
+ <Button size="sm" variant="outline" disabled={!results.data.hasNext || results.loading} onClick={() => setSubmitted({ ...submitted, page: submitted.page + 1 })}>Next</Button>
101
+ </div>
102
+ </div>
103
+ ) : null}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TagManager: the inbox tag library. See every tag, create one, rename or recolour one, delete one.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. GET /workflows/inbox-tags (workflows.listInboxTags) READ - every tag, with its built-in policy
8
+ * 2. POST /inbox/tags (aiInbox.createInboxTag) write
9
+ * 3. PUT /inbox/tags/{tag_id} (aiInbox.updateInboxTag) write - ONLY the fields that changed
10
+ * 4. DELETE /inbox/tags/{tag_id} (aiInbox.deleteInboxTag) DESTRUCTIVE -> ConfirmSpendDialog first
11
+ *
12
+ * Built-in tags follow the API's policy: "system" tags cannot be edited or deleted, "sdr_ai" tags cannot be renamed
13
+ * or deleted. Deleting a tag removes the label from every conversation that carries it (nothing else is removed).
14
+ * The edit form is seeded from a LOADED row and its Save stays disabled until something changed; it never writes a
15
+ * tag that was not loaded, and the request carries only the changed fields (a partial PUT must not reset
16
+ * `ai_can_apply` or the rules, which this form does not show). Applying a tag to a conversation is TagAndAssign.
17
+ */
18
+ import { useEffect, useState } from "react";
19
+ import { Pencil, Plus, Trash2 } from "lucide-react";
20
+ import { Button, Input, Skeleton } from "@graph8/sdk/ui";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
23
+ import { EmptyState } from "@/g8/components/empty-state";
24
+ import { StatusPill } from "@/g8/components/screen/status-pill";
25
+ import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
26
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
27
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
28
+ import type { InboxTag } from "../lib/inbox-types";
29
+ import { asInboxTags, canDeleteTag, canEditTag, canRenameTag, isHexColor, managedTags, tagChanges } from "../lib/inbox-manage";
30
+ import type { ManagedTag } from "../lib/inbox-manage";
31
+
32
+ export interface TagManagerProps {
33
+ /** Called with the current tag list after every load and change, so a screen can keep its pickers in step. */
34
+ onTags?: (tags: InboxTag[]) => void;
35
+ className?: string;
36
+ }
37
+
38
+ function TagForm({ tag, onDone }: { tag: ManagedTag | null; onDone: (changed: boolean) => void }) {
39
+ const [name, setName] = useState(tag?.name ?? "");
40
+ const [description, setDescription] = useState(tag?.description ?? "");
41
+ const [color, setColor] = useState(tag?.color ?? "");
42
+ useEffect(() => {
43
+ setName(tag?.name ?? "");
44
+ setDescription(tag?.description ?? "");
45
+ setColor(tag?.color ?? "");
46
+ }, [tag?.id]); // eslint-disable-line react-hooks/exhaustive-deps
47
+ const changes = tag ? tagChanges(tag, { name, description, color }) : {};
48
+ const save = useG8Mutation(async () => {
49
+ if (tag) await g8Put(`/inbox/tags/${encodeURIComponent(tag.id)}`, changes);
50
+ else await g8Post("/inbox/tags", { name: name.trim(), ...(description.trim() ? { description: description.trim() } : {}), ...(color.trim() ? { color: color.trim() } : {}) });
51
+ }, { invalidates: ["inbox"] });
52
+ const valid = name.trim().length > 0 && isHexColor(color) && (tag ? Object.keys(changes).length > 0 : true);
53
+ const submit = async () => {
54
+ try {
55
+ await save.mutateAsync(undefined);
56
+ onDone(true);
57
+ } catch {
58
+ /* the error renders in the form; the typed values stay */
59
+ }
60
+ };
61
+ return (
62
+ <form className="space-y-2 rounded-md border border-border p-3" onSubmit={(e) => { e.preventDefault(); if (valid) void submit(); }} aria-label={tag ? `Edit ${tag.name}` : "New tag"}>
63
+ <Input aria-label="Tag name" placeholder="Tag name" value={name} disabled={tag ? !canRenameTag(tag) : false} onChange={(e) => setName(e.target.value)} />
64
+ <Input aria-label="Description" placeholder="What this tag means (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
65
+ <div className="flex items-center gap-2">
66
+ <Input aria-label="Colour" className="w-32" placeholder="#eab308" value={color} onChange={(e) => setColor(e.target.value)} />
67
+ {!isHexColor(color) ? <span role="alert" className="text-xs text-error-foreground">Use a hex colour such as #eab308.</span> : null}
68
+ </div>
69
+ {save.error ? <ApiError error={save.error} title={tag ? "Could not save the tag" : "Could not create the tag"} /> : null}
70
+ <div className="flex justify-end gap-2">
71
+ <Button type="button" variant="outline" size="sm" onClick={() => onDone(false)}>Cancel</Button>
72
+ <Button type="submit" size="sm" disabled={!valid || save.loading}>{tag ? "Save changes" : "Create tag"}</Button>
73
+ </div>
74
+ </form>
75
+ );
76
+ }
77
+
78
+ export function TagManager({ onTags, className }: TagManagerProps) {
79
+ const [editing, setEditing] = useState<ManagedTag | "new" | null>(null);
80
+ const [deleting, setDeleting] = useState<ManagedTag | null>(null);
81
+ const tags = useG8Query(async (signal) => managedTags(await g8Get("/workflows/inbox-tags", undefined, { signal })), [], { key: ["inbox", "tags"] });
82
+ useEffect(() => { if (tags.data) onTags?.(asInboxTags(tags.data)); }, [tags.data]); // eslint-disable-line react-hooks/exhaustive-deps
83
+ const remove = useG8Mutation(async (tag: ManagedTag) => {
84
+ await g8Delete(`/inbox/tags/${encodeURIComponent(tag.id)}`);
85
+ setDeleting(null);
86
+ await tags.refetch();
87
+ }, { invalidates: ["inbox"], onError: () => setDeleting(null) });
88
+
89
+ if (tags.error) return <ApiError error={tags.error} title="Could not load the tags" onRetry={() => void tags.refetch()} className={className} />;
90
+ if (tags.loading && !tags.data) return <div aria-busy="true" className={`space-y-2 ${className ?? ""}`}><Skeleton className="h-10 w-full" /><Skeleton className="h-10 w-full" /></div>;
91
+ const rows = tags.data ?? [];
92
+ const done = (changed: boolean) => { setEditing(null); if (changed) void tags.refetch(); };
93
+
94
+ return (
95
+ <div className={`space-y-3 ${className ?? ""}`}>
96
+ <div className="flex items-center justify-between">
97
+ <h3 className="font-medium">Tags</h3>
98
+ <Button size="sm" variant="outline" disabled={editing != null} onClick={() => setEditing("new")}><Plus className="mr-1 size-4" aria-hidden />New tag</Button>
99
+ </div>
100
+ {editing === "new" ? <TagForm tag={null} onDone={done} /> : null}
101
+ {rows.length === 0 && editing !== "new" ? (
102
+ <EmptyState compact title="No tags yet" description="Tags label conversations so you can filter and route them." action={<Button size="sm" onClick={() => setEditing("new")}>Create a tag</Button>} />
103
+ ) : (
104
+ <ul aria-label="Tags" className="divide-y rounded-md border border-border">
105
+ {rows.map((t) => (
106
+ <li key={t.id} className="p-2 text-sm">
107
+ {editing !== "new" && editing?.id === t.id ? (
108
+ <TagForm tag={t} onDone={done} />
109
+ ) : (
110
+ <div className="flex items-center gap-2">
111
+ <span aria-hidden className="size-3 shrink-0 rounded-full border border-border" style={t.color ? { backgroundColor: t.color } : undefined} />
112
+ <span className="min-w-0 flex-1"><span className="font-medium">{t.name}</span>{t.description ? <span className="block truncate text-xs text-muted-foreground">{t.description}</span> : null}</span>
113
+ {t.policy !== "user" ? <StatusPill tone="neutral">built in</StatusPill> : null}
114
+ <Button size="sm" variant="ghost" aria-label={`Edit ${t.name}`} disabled={!canEditTag(t) || editing != null} onClick={() => setEditing(t)}><Pencil className="size-4" aria-hidden /></Button>
115
+ <Button size="sm" variant="ghost" aria-label={`Delete ${t.name}`} disabled={!canDeleteTag(t)} onClick={() => setDeleting(t)}><Trash2 className="size-4" aria-hidden /></Button>
116
+ </div>
117
+ )}
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ )}
122
+ {remove.error ? <ApiError error={remove.error} title="Could not delete the tag" /> : null}
123
+ <ConfirmSpendDialog
124
+ open={deleting != null}
125
+ tier="destructive"
126
+ title={`Delete the tag "${deleting?.name ?? ""}"?`}
127
+ description="Every conversation loses this label. The conversations themselves are not touched. This cannot be undone."
128
+ confirmLabel="Delete tag"
129
+ affected={1}
130
+ confirming={remove.loading}
131
+ onConfirm={() => deleting && void remove.mutate(deleting)}
132
+ onCancel={() => setDeleting(null)}
133
+ />
134
+ </div>
135
+ );
136
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ThreadActions: the small things you do to one conversation besides replying: mark it read (or, on LinkedIn,
5
+ * unread), take a tag off, take a teammate off, and block the sender.
6
+ *
7
+ * SDK operations, in order (the thread's own id and channel decide the route):
8
+ * Read state POST /inbox/emails/{id}/mark-read (aiInbox.markInboxEmailRead) write
9
+ * POST /inbox/sms/{id}/mark-read (aiInbox.markInboxSmsRead) write
10
+ * POST /inbox/linkedin/accounts/{account}/conversations/{id}/read|unread (aiInbox.markInboxLinkedinConversationRead|Unread) write
11
+ * There is no "mark unread" for email or SMS in the API, so those offer only "Mark as read".
12
+ * Remove tag DELETE /inbox/channels/{id}/tags?tag_id&channel_type (aiInbox.removeChannelTag) DESTRUCTIVE -> ConfirmSpendDialog
13
+ * Unassign DELETE /inbox/channels/{id}/assign?assignee_email&channel_type (aiInbox.unassignChannel) DESTRUCTIVE -> ConfirmSpendDialog
14
+ * Block sender POST /inbox/blocked-senders { mailbox, sender_email } (aiInbox.blockInboxSender) write, email threads only;
15
+ * asked inline first because future mail from them stops arriving (BlockedSenders can undo it)
16
+ * Applying a tag and assigning are TagAndAssign. Nothing here sends a message.
17
+ */
18
+ import { useState } from "react";
19
+ import { Ban, CheckCheck, MailOpen, X } from "lucide-react";
20
+ import { Button } from "@graph8/sdk/ui";
21
+ import { ApiError } from "@/g8/components/api-error";
22
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
23
+ import { g8Delete, g8Post } from "@/g8/lib/g8-client";
24
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
25
+ import { canMarkRead, canMarkUnread, channelTypeOf } from "../lib/inbox-manage";
26
+ import type { InboxThread } from "../lib/inbox-types";
27
+
28
+ type Pending = { kind: "tag"; id: string; name: string } | { kind: "assignee"; email: string; name: string } | null;
29
+
30
+ export interface ThreadActionsProps {
31
+ thread: InboxThread;
32
+ /** Called after any change; reload the thread here. */
33
+ onChanged?: () => void;
34
+ className?: string;
35
+ }
36
+
37
+ function readPath(thread: InboxThread, read: boolean): string | null {
38
+ const id = encodeURIComponent(thread.id);
39
+ if (thread.channel === "sms") return read ? `/inbox/sms/${id}/mark-read` : null;
40
+ if (thread.channel === "linkedin") {
41
+ return thread.linkedin_account_id == null ? null : `/inbox/linkedin/accounts/${thread.linkedin_account_id}/conversations/${id}/${read ? "read" : "unread"}`;
42
+ }
43
+ return read ? `/inbox/emails/${id}/mark-read` : null;
44
+ }
45
+
46
+ export function ThreadActions({ thread, onChanged, className }: ThreadActionsProps) {
47
+ const [pending, setPending] = useState<Pending>(null);
48
+ const [blocking, setBlocking] = useState(false);
49
+ const [notice, setNotice] = useState<string | null>(null);
50
+ const channelType = channelTypeOf(thread.channel);
51
+ const sender = thread.channel === "email" ? (thread.contact?.email ?? "") : "";
52
+ const channelQuery = { channel_type: channelType, linkedin_account_id: thread.channel === "linkedin" ? thread.linkedin_account_id : undefined };
53
+ const done = (message: string) => { setNotice(message); onChanged?.(); };
54
+
55
+ const mark = useG8Mutation(async (read: boolean) => {
56
+ const path = readPath(thread, read);
57
+ if (!path) throw new Error("This conversation cannot be marked that way.");
58
+ await g8Post(path);
59
+ done(read ? "Marked as read." : "Marked as unread.");
60
+ }, { invalidates: ["inbox"] });
61
+ const remove = useG8Mutation(async (p: NonNullable<Pending>) => {
62
+ const base = `/inbox/channels/${encodeURIComponent(thread.id)}`;
63
+ if (p.kind === "tag") await g8Delete(`${base}/tags`, { query: { ...channelQuery, tag_id: p.id } });
64
+ else await g8Delete(`${base}/assign`, { query: { ...channelQuery, assignee_email: p.email } });
65
+ done(p.kind === "tag" ? `Removed the tag "${p.name}".` : `Unassigned ${p.name}.`);
66
+ }, { invalidates: ["inbox"] });
67
+ const block = useG8Mutation(async () => {
68
+ await g8Post("/inbox/blocked-senders", { mailbox: thread.mailbox, sender_email: sender });
69
+ setBlocking(false);
70
+ done(`Blocked ${sender}.`);
71
+ }, { invalidates: ["inbox"] });
72
+
73
+ const confirm = async () => {
74
+ const current = pending;
75
+ if (!current) return;
76
+ try {
77
+ await remove.mutateAsync(current);
78
+ } catch {
79
+ /* surfaced below */
80
+ }
81
+ setPending(null);
82
+ };
83
+ const canBlock = thread.channel === "email" && Boolean(thread.mailbox) && Boolean(sender);
84
+ const label = (n: string | null | undefined, fallback: string) => n || fallback;
85
+
86
+ return (
87
+ <section aria-label="Conversation actions" className={`space-y-3 rounded-lg border p-3 text-sm ${className ?? ""}`}>
88
+ <h3 className="font-medium">Manage conversation</h3>
89
+ <div className="flex flex-wrap gap-2">
90
+ {canMarkRead(thread) ? <Button size="sm" variant="outline" loading={mark.loading} onClick={() => void mark.mutate(true)}><MailOpen className="mr-1 size-4" aria-hidden />Mark as read</Button> : null}
91
+ {canMarkUnread(thread) ? <Button size="sm" variant="outline" disabled={mark.loading} onClick={() => void mark.mutate(false)}>Mark as unread</Button> : null}
92
+ {canBlock && !blocking ? <Button size="sm" variant="outline" onClick={() => setBlocking(true)}><Ban className="mr-1 size-4" aria-hidden />Block sender</Button> : null}
93
+ </div>
94
+ {blocking ? (
95
+ <div role="group" aria-label="Block this sender" className="flex flex-wrap items-center gap-2 rounded-md border border-border p-2">
96
+ <span>Stop receiving mail from {sender} in {thread.mailbox}?</span>
97
+ <Button size="sm" loading={block.loading} onClick={() => void block.mutate(undefined)}>Block {sender}</Button>
98
+ <Button size="sm" variant="ghost" onClick={() => setBlocking(false)}>Cancel</Button>
99
+ </div>
100
+ ) : null}
101
+ {thread.tags.length > 0 || thread.assignees.length > 0 ? (
102
+ <ul aria-label="Remove from conversation" className="space-y-1">
103
+ {thread.tags.filter((t) => t.id).map((t) => (
104
+ <li key={`t-${t.id}`} className="flex items-center justify-between gap-2">
105
+ <span>Tag: {label(t.name, String(t.id))}</span>
106
+ <Button size="sm" variant="ghost" aria-label={`Remove tag ${label(t.name, String(t.id))}`} onClick={() => setPending({ kind: "tag", id: String(t.id), name: label(t.name, String(t.id)) })}><X className="size-4" aria-hidden /></Button>
107
+ </li>
108
+ ))}
109
+ {thread.assignees.map((a) => (
110
+ <li key={`a-${a.email}`} className="flex items-center justify-between gap-2">
111
+ <span>Assigned to {label(a.name, a.email)}</span>
112
+ <Button size="sm" variant="ghost" aria-label={`Unassign ${label(a.name, a.email)}`} onClick={() => setPending({ kind: "assignee", email: a.email, name: label(a.name, a.email) })}><X className="size-4" aria-hidden /></Button>
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ ) : null}
117
+ {[mark.error, remove.error, block.error].map((e, i) => (e ? <ApiError key={i} error={e} title="Could not update the conversation" /> : null))}
118
+ {notice ? <p role="status" className="text-xs text-muted-foreground"><CheckCheck className="mr-1 inline size-3" aria-hidden />{notice}</p> : null}
119
+
120
+ <ConfirmSpendDialog
121
+ open={pending != null}
122
+ tier="destructive"
123
+ title={pending?.kind === "tag" ? `Remove the tag "${pending.name}"?` : `Unassign ${pending?.name ?? ""}?`}
124
+ description={pending?.kind === "tag" ? "The tag comes off this conversation only. Other conversations keep it." : "They no longer own this conversation. You can assign them again later."}
125
+ confirmLabel={pending?.kind === "tag" ? "Remove tag" : "Unassign"}
126
+ affected={1}
127
+ confirming={remove.loading}
128
+ onConfirm={() => void confirm()}
129
+ onCancel={() => setPending(null)}
130
+ />
131
+ </section>
132
+ );
133
+ }
@@ -18,6 +18,7 @@ import { ApiError } from "@/g8/components/api-error";
18
18
  import { EmptyState } from "@/g8/components/empty-state";
19
19
  import { useInboxThread } from "../hooks/use-inbox-thread";
20
20
  import { contactLabel, formatWhen, stripHtml, type InboxMessage, type InboxThread } from "../lib/inbox-types";
21
+ import { isNotFound } from "../lib/inbox-manage";
21
22
 
22
23
  export interface ThreadViewProps {
23
24
  /** A row from InboxList. Only `id` and `channel` are required; the rest is loaded fresh. */
@@ -73,6 +74,7 @@ function ThreadHeader({ thread }: { thread: InboxThread }) {
73
74
  export function ThreadView({ thread: row, children, className }: ThreadViewProps) {
74
75
  const { data, loading, error, refetch } = useInboxThread(row.id, row.channel);
75
76
 
77
+ if (isNotFound(error)) return <EmptyState title="Conversation not found" description="It may have been removed, or it is not shared with this API key. Hidden conversations answer exactly like missing ones." />;
76
78
  if (error) return <ApiError error={error} title="Could not load this conversation" onRetry={() => void refetch()} />;
77
79
  if (loading && !data) {
78
80
  return (
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useInboxMailboxes: EVERY mailbox the caller can see, with whether they may send as it. One page of 100 is read, then the
5
+ * next, until the API says there is no more (at most 20 pages); `truncated` is true when that cap was hit, and the picker
6
+ * must say so (FirstNotice). A mailbox beyond the first page can therefore never be silently missing from a sender picker.
7
+ *
8
+ * SDK operations: GET /inbox/mailboxes/paginated?page&page_size (aiInbox.listInboxMailboxesPaginated), read tier.
9
+ */
10
+ import { g8Get } from "@/g8/lib/g8-client";
11
+ import { useAllPages } from "@/g8/hooks/use-all-pages";
12
+ import { hasNextOf } from "@/g8/lib/paging";
13
+ import { mailboxChoices } from "../lib/inbox-manage";
14
+ import type { MailboxChoice } from "../lib/inbox-manage";
15
+
16
+ const PAGE = 100;
17
+
18
+ export function useInboxMailboxes(options: { enabled?: boolean } = {}) {
19
+ const all = useAllPages<MailboxChoice>(
20
+ async (page, signal) => {
21
+ const env = await g8Get("/inbox/mailboxes/paginated", { page, page_size: PAGE }, { signal });
22
+ const rows = mailboxChoices(env);
23
+ return { rows, hasNext: hasNextOf(env, PAGE, rows.length) };
24
+ },
25
+ [],
26
+ { enabled: options.enabled, pageSize: PAGE },
27
+ );
28
+ const seen = new Set<string>();
29
+ const items = all.items.filter((m) => !seen.has(m.address.toLowerCase()) && seen.add(m.address.toLowerCase()));
30
+ return { ...all, items, data: all.loaded ? items : undefined };
31
+ }