@sentry/junior-dashboard 0.273.0 → 0.274.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.273.0",
3
+ "version": "0.274.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,10 +39,10 @@
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "4.1.0",
41
41
  "zod": "^4.5.4",
42
- "@sentry/junior-plugin-api": "0.273.0"
42
+ "@sentry/junior-plugin-api": "0.274.0"
43
43
  },
44
44
  "peerDependencies": {
45
- "@sentry/junior": "0.273.0"
45
+ "@sentry/junior": "0.274.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@oxlint/plugins": "1.79.0",
@@ -63,7 +63,7 @@
63
63
  "tw-animate-css": "^1.4.0",
64
64
  "typescript": "^7.0.2",
65
65
  "vitest": "^4.1.12",
66
- "@sentry/junior": "0.273.0"
66
+ "@sentry/junior": "0.274.0"
67
67
  },
68
68
  "scripts": {
69
69
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:client && pnpm run build:css && pnpm run build:server",
@@ -26,6 +26,7 @@ import {
26
26
  } from "../components/ui/tooltip";
27
27
  import { useDashboardOnline } from "../connection";
28
28
  import { cn, dashboardComposerSurfaceClass } from "../styles";
29
+ import { useComposerAutocorrect } from "./composerAutocorrect";
29
30
 
30
31
  const DRAFT_STORAGE_PREFIX = "junior:dashboard:conversation-draft:";
31
32
  // Keep keystrokes off the storage path. Mobile Safari pays for every write.
@@ -132,6 +133,7 @@ export const ConversationComposer = memo(function ConversationComposer(
132
133
  const [dragging, setDragging] = useState(false);
133
134
  const fileInputRef = useRef<HTMLInputElement>(null);
134
135
  const online = useDashboardOnline();
136
+ const [autocorrect] = useComposerAutocorrect();
135
137
  const id = useId();
136
138
  const textareaRef = useRef<HTMLTextAreaElement>(null);
137
139
  const draftRef = useRef(initialDraft);
@@ -490,10 +492,11 @@ export const ConversationComposer = memo(function ConversationComposer(
490
492
  </label>
491
493
  <textarea
492
494
  // Chat is technical: ids, paths, code. Keep the keyboard chat-like
493
- // (Send) and suppress browser IME assist that fights identifiers.
494
- autoCapitalize="off"
495
+ // (Send) and suppress browser IME assist that fights identifiers,
496
+ // unless the user turns autocorrect on in settings.
497
+ autoCapitalize={autocorrect ? "sentences" : "off"}
495
498
  autoComplete="off"
496
- autoCorrect="off"
499
+ autoCorrect={autocorrect ? "on" : "off"}
497
500
  className="min-h-12 max-h-28 w-full resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 font-sans text-base leading-relaxed text-dashboard-text outline-none placeholder:text-dashboard-text-muted/65 md:min-h-14 md:max-h-[min(calc(12lh_+_1.5rem),40dvh)] md:px-4 md:py-3"
498
501
  disabled={Boolean(props.restoreDraftOnError && createPending)}
499
502
  enterKeyHint="send"
@@ -510,7 +513,7 @@ export const ConversationComposer = memo(function ConversationComposer(
510
513
  placeholder="Message Junior…"
511
514
  ref={textareaRef}
512
515
  rows={1}
513
- spellCheck={false}
516
+ spellCheck={autocorrect}
514
517
  />
515
518
  <div
516
519
  className={cn(
@@ -1,20 +1,27 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
2
- import { Archive, ArchiveRestore, CircleAlert, SquarePen } from "lucide-react";
2
+ import {
3
+ Archive,
4
+ ArchiveRestore,
5
+ ChevronRight,
6
+ CircleAlert,
7
+ Inbox,
8
+ LockKeyhole,
9
+ SquarePen,
10
+ } from "lucide-react";
3
11
  import { Link } from "react-router";
4
12
 
5
13
  import { useArchiveConversation } from "./queries";
6
14
  import { conversationPath } from "./conversationRoutes";
7
15
  import {
8
16
  conversationDisplayTitle,
9
- slackLocationLabel,
10
17
  visualStatusForConversation,
11
18
  } from "../format";
19
+ import { formatCompactAge } from "./conversationAge";
12
20
  import { cn } from "../styles";
13
21
  import type { Conversation } from "../types";
14
22
  import { Notice, NoticeAction } from "../components/Notice";
15
23
  import { AnimatedList } from "./AnimatedList";
16
24
  import {
17
- attentionConversationIds,
18
25
  buildConversationSections,
19
26
  type ConversationSection,
20
27
  } from "./conversationSections";
@@ -29,8 +36,8 @@ import {
29
36
  TooltipContent,
30
37
  TooltipTrigger,
31
38
  } from "../components/ui/tooltip";
32
- import { ConversationSidebarAnnotations } from "./ConversationMeta";
33
- import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
39
+ import { ShimmerText } from "../components/ShimmerText";
40
+ import type { WaitingConversation } from "../review-desk/queries";
34
41
 
35
42
  type ConversationSidebarEntry =
36
43
  | {
@@ -55,11 +62,13 @@ export function ConversationSidebar(props: {
55
62
  conversations: Conversation[];
56
63
  error?: string;
57
64
  finishedConversationIds: ReadonlySet<string>;
65
+ /** Opened while unread; keeps its unread place until another one opens. */
66
+ heldConversationId?: string;
58
67
  loading: boolean;
59
68
  query: string;
60
69
  selectedId?: string;
61
70
  timeZone: string;
62
- waitingConversationIds: ReadonlySet<string>;
71
+ waitingConversations: ReadonlyMap<string, WaitingConversation>;
63
72
  /**
64
73
  * `panel` = split-pane dock with internal scroll.
65
74
  * `landing` = flow nav under create compose (parent owns scroll).
@@ -97,10 +106,11 @@ export function ConversationSidebar(props: {
97
106
  () =>
98
107
  conversationSidebarEntries(
99
108
  buildConversationSections(props.conversations, {
100
- attentionIds: attentionConversationIds(
101
- props.waitingConversationIds,
102
- props.finishedConversationIds,
103
- ),
109
+ attentionIds: new Set([
110
+ ...props.waitingConversations.keys(),
111
+ ...props.finishedConversationIds,
112
+ ...(props.heldConversationId ? [props.heldConversationId] : []),
113
+ ]),
104
114
  nowMs: Date.now(),
105
115
  timeZone: props.timeZone,
106
116
  }),
@@ -110,8 +120,9 @@ export function ConversationSidebar(props: {
110
120
  collapsedKeys,
111
121
  props.conversations,
112
122
  props.finishedConversationIds,
123
+ props.heldConversationId,
113
124
  props.timeZone,
114
- props.waitingConversationIds,
125
+ props.waitingConversations,
115
126
  ],
116
127
  );
117
128
  return (
@@ -204,13 +215,13 @@ export function ConversationSidebar(props: {
204
215
  ) : (
205
216
  <ConversationSidebarRow
206
217
  conversation={entry.conversation}
207
- finishedSinceSeen={props.finishedConversationIds.has(
208
- entry.conversation.id,
209
- )}
210
218
  onArchiveError={handleArchiveError}
211
219
  onArchived={handleArchived}
212
220
  selected={entry.conversation.id === props.selectedId}
213
- waitingOnViewer={props.waitingConversationIds.has(
221
+ unread={props.finishedConversationIds.has(
222
+ entry.conversation.id,
223
+ )}
224
+ waiting={props.waitingConversations.get(
214
225
  entry.conversation.id,
215
226
  )}
216
227
  />
@@ -262,12 +273,15 @@ function ConversationRowsLoading() {
262
273
  >
263
274
  <span className="sr-only">Loading your conversations</span>
264
275
  <Skeleton className="mb-1 ml-2.5 mt-1.5 h-2.5 w-12" />
265
- {Array.from({ length: 7 }, (_, index) => (
266
- <div className="grid gap-1 rounded-lg px-3 py-2" key={index}>
276
+ {Array.from({ length: 10 }, (_, index) => (
277
+ <div
278
+ className="flex items-center justify-between gap-3 rounded-lg py-2 pl-[1.625rem] pr-3"
279
+ key={index}
280
+ >
267
281
  <Skeleton
268
282
  className={cn("h-4", index % 3 === 0 ? "w-4/5" : "w-3/5")}
269
283
  />
270
- <Skeleton className="h-3 w-2/5 opacity-70" />
284
+ <Skeleton className="h-3 w-6 opacity-70" />
271
285
  </div>
272
286
  ))}
273
287
  </div>
@@ -301,11 +315,11 @@ function conversationSidebarEntries(
301
315
 
302
316
  const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
303
317
  conversation: Conversation;
304
- finishedSinceSeen: boolean;
305
318
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
306
319
  onArchived(conversation: Conversation): void;
307
320
  selected: boolean;
308
- waitingOnViewer: boolean;
321
+ unread: boolean;
322
+ waiting?: WaitingConversation;
309
323
  }) {
310
324
  const archive = useArchiveConversation(props.conversation.id, {
311
325
  // Read the attempted direction from the mutation call, not the (possibly
@@ -321,60 +335,82 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
321
335
  },
322
336
  });
323
337
  const status = visualStatusForConversation(props.conversation);
324
- const location = slackLocationLabel(props.conversation, {
325
- includeId: false,
326
- });
327
338
  const title = conversationDisplayTitle(props.conversation);
328
- const hasAnnotations = Boolean(props.conversation.sidebarAnnotations?.length);
329
- const isPrivate = props.conversation.visibility === "private";
330
- // Linked work is denser and more actionable than channel; hide channel when
331
- // annotations own the meta row. Also skip a location that only restates the
332
- // title (common for redacted private destinations).
333
- const showLocation =
334
- Boolean(location) &&
335
- !hasAnnotations &&
336
- location?.toLocaleLowerCase() !== title.toLocaleLowerCase();
337
- const hasMeta = showLocation || hasAnnotations;
339
+ // Unread and waiting rows read like unread mail: a dot and a bold title.
340
+ const emphasized = props.unread || Boolean(props.waiting);
338
341
  return (
339
- <div className="mobile-conversation-row group relative min-w-0">
342
+ <div
343
+ className={cn(
344
+ "mobile-conversation-row group relative min-w-0 rounded-lg transition-colors hover:bg-dashboard-fill-hover",
345
+ props.selected && "bg-dashboard-surface-active",
346
+ )}
347
+ >
340
348
  <Link
341
349
  aria-current={props.selected ? "page" : undefined}
342
- className={cn(
343
- "block min-w-0 rounded-lg px-3 py-2 text-inherit no-underline transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus max-sm:pr-10",
344
- props.selected && "bg-dashboard-surface-active",
345
- )}
350
+ className="grid min-w-0 grid-cols-[0.375rem_minmax(0,1fr)_auto] items-center gap-x-2 rounded-lg px-3 py-1.5 text-inherit no-underline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus max-sm:pr-10"
346
351
  to={conversationPath(props.conversation.id)}
347
352
  >
348
- <div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-x-1.5">
349
- <div className="col-start-1 row-start-1 mt-[0.3rem] grid size-3 shrink-0 place-items-center">
350
- <ConversationListStatusIcon
351
- finishedSinceSeen={props.finishedSinceSeen}
352
- isPrivate={isPrivate}
353
- status={status}
354
- waitingOnViewer={props.waitingOnViewer}
353
+ <span className="grid place-items-center">
354
+ {emphasized ? (
355
+ <span
356
+ aria-label={props.waiting ? "Waiting on you" : "Unread"}
357
+ className="size-1.5 shrink-0 rounded-full bg-dashboard-accent"
358
+ role="img"
359
+ />
360
+ ) : props.conversation.visibility === "private" ? (
361
+ <LockKeyhole
362
+ aria-label="Private conversation"
363
+ className="size-3 shrink-0 text-dashboard-text-faint"
355
364
  />
356
- </div>
357
- <div className="col-start-2 row-start-1 min-w-0 truncate font-sans text-sm font-medium leading-snug text-dashboard-text">
358
- {title}
359
- </div>
360
- {hasMeta ? (
361
- <div className="col-start-2 row-start-2 mt-1 flex min-w-0 items-center gap-1.5 font-sans text-xs leading-tight text-dashboard-text-muted">
362
- {showLocation ? (
363
- <span className="truncate">{location}</span>
364
- ) : null}
365
- <ConversationSidebarAnnotations
366
- annotations={props.conversation.sidebarAnnotations}
367
- />
368
- </div>
369
365
  ) : null}
370
- </div>
366
+ </span>
367
+ <span
368
+ className={cn(
369
+ "min-w-0 truncate font-sans text-sm leading-snug",
370
+ emphasized
371
+ ? "font-semibold text-dashboard-text"
372
+ : status === "active"
373
+ ? "text-dashboard-text"
374
+ : "text-dashboard-text-muted",
375
+ )}
376
+ >
377
+ {title}
378
+ </span>
379
+ <ConversationRowState
380
+ lastSeenAt={props.conversation.lastSeenAt}
381
+ status={status}
382
+ />
371
383
  </Link>
384
+ {props.waiting ? (
385
+ <Link
386
+ className="mb-1.5 ml-[1.625rem] mr-3 flex min-w-0 items-center gap-1.5 rounded-md border border-dashboard-border-strong bg-dashboard-surface-raised px-2 py-1 font-sans text-xs text-dashboard-text no-underline transition-colors hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus"
387
+ to={conversationPath(props.conversation.id, { reviewItems: true })}
388
+ >
389
+ <Inbox
390
+ aria-hidden="true"
391
+ className="size-3 shrink-0 text-dashboard-text-muted"
392
+ />
393
+ <span className="min-w-0 truncate">{props.waiting.title}</span>
394
+ {props.waiting.count > 1 ? (
395
+ <span className="ml-auto shrink-0 text-dashboard-text-faint">
396
+ +{props.waiting.count - 1} more
397
+ </span>
398
+ ) : null}
399
+ <ChevronRight
400
+ aria-hidden="true"
401
+ className={cn(
402
+ "size-3 shrink-0 text-dashboard-text-muted",
403
+ props.waiting.count > 1 ? undefined : "ml-auto",
404
+ )}
405
+ />
406
+ </Link>
407
+ ) : null}
372
408
  <Tooltip>
373
409
  <TooltipTrigger
374
410
  render={
375
411
  <button
376
412
  aria-label={`${props.conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
377
- className="absolute right-1.5 top-1/2 z-10 grid size-7 -translate-y-1/2 cursor-pointer place-items-center rounded-md bg-dashboard-surface-active text-dashboard-text-muted transition hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus sm:pointer-events-none sm:opacity-0 sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 disabled:cursor-not-allowed"
413
+ className="absolute right-1.5 top-0.5 z-10 grid size-7 cursor-pointer place-items-center rounded-md bg-dashboard-surface-active text-dashboard-text-muted transition hover:text-dashboard-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus sm:pointer-events-none sm:opacity-0 sm:focus:pointer-events-auto sm:focus:opacity-100 sm:group-focus-within:pointer-events-auto sm:group-focus-within:opacity-100 sm:group-hover:pointer-events-auto sm:group-hover:opacity-100 disabled:cursor-not-allowed"
378
414
  disabled={archive.isPending}
379
415
  onClick={() =>
380
416
  archive.mutate({
@@ -400,6 +436,31 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
400
436
  );
401
437
  });
402
438
 
439
+ /** Show the row age, or the status when the conversation runs or failed. */
440
+ function ConversationRowState(props: {
441
+ lastSeenAt: string;
442
+ status: ReturnType<typeof visualStatusForConversation>;
443
+ }) {
444
+ if (props.status === "active") {
445
+ return (
446
+ <ShimmerText
447
+ active
448
+ className="font-sans text-xs text-dashboard-text-muted"
449
+ >
450
+ Running
451
+ </ShimmerText>
452
+ );
453
+ }
454
+ if (props.status === "failed") {
455
+ return <span className="font-sans text-xs text-rose-400">Failed</span>;
456
+ }
457
+ return (
458
+ <span className="font-sans text-xs tabular-nums text-dashboard-text-faint">
459
+ {formatCompactAge(props.lastSeenAt)}
460
+ </span>
461
+ );
462
+ }
463
+
403
464
  function ArchiveConversationErrorNotice(props: {
404
465
  conversation: Conversation;
405
466
  onDismiss(): void;
@@ -24,7 +24,10 @@ import { buildConversations, getDashboardTimeZone } from "../format";
24
24
  import { useDebouncedValue } from "../debouncedValue";
25
25
  import type { Conversation } from "../types";
26
26
  import { ConversationPage } from "./ConversationPage";
27
- import { useWaitingConversationIds } from "../review-desk/queries";
27
+ import {
28
+ useWaitingConversationIds,
29
+ useWaitingConversations,
30
+ } from "../review-desk/queries";
28
31
  import { UnsentAnswersProvider } from "../review-desk/UnsentAnswers";
29
32
  import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";
30
33
 
@@ -56,8 +59,9 @@ export function ConversationWorkspace() {
56
59
  if (selectedId) navigate(NEW_CONVERSATION_PATH);
57
60
  };
58
61
 
62
+ const waitingConversations = useWaitingConversations();
59
63
  const waitingConversationIds = useWaitingConversationIds();
60
- const { finishedConversationIds, markRead } =
64
+ const { finishedConversationIds, heldConversationId, markRead } =
61
65
  useConversationFinishedIndicators(
62
66
  conversations,
63
67
  selectedId,
@@ -149,13 +153,14 @@ export function ConversationWorkspace() {
149
153
  conversations={conversations}
150
154
  error={feed.error?.message}
151
155
  finishedConversationIds={finishedConversationIds}
156
+ heldConversationId={heldConversationId}
152
157
  loading={feed.isPending}
153
158
  onNewConversation={openCreate}
154
159
  onQueryChange={setQuery}
155
160
  query={query}
156
161
  selectedId={selectedId}
157
162
  timeZone={getDashboardTimeZone()}
158
- waitingConversationIds={waitingConversationIds}
163
+ waitingConversations={waitingConversations}
159
164
  />
160
165
  </div>
161
166
  <section
@@ -0,0 +1,12 @@
1
+ import { useLocalStorage } from "@uidotdev/usehooks";
2
+
3
+ const AUTOCORRECT_STORAGE_KEY = "junior:composer:autocorrect";
4
+
5
+ /**
6
+ * Edit the browser-only choice to let the keyboard autocorrect, spell check,
7
+ * and capitalize the message composer. Off by default because these change
8
+ * ids, paths, and code.
9
+ */
10
+ export function useComposerAutocorrect() {
11
+ return useLocalStorage<boolean>(AUTOCORRECT_STORAGE_KEY, false);
12
+ }
@@ -0,0 +1,24 @@
1
+ import { getDashboardTimeZone } from "../format";
2
+
3
+ /**
4
+ * Format an activity time as a short age for dense lists: `now`, `12m`, `3h`,
5
+ * `2d`, `3w`, then a month and day.
6
+ */
7
+ export function formatCompactAge(value: string, nowMs = Date.now()): string {
8
+ const time = Date.parse(value);
9
+ if (!Number.isFinite(time)) return "";
10
+
11
+ const minutes = Math.floor(Math.max(0, nowMs - time) / 60_000);
12
+ if (minutes < 1) return "now";
13
+ if (minutes < 60) return `${minutes}m`;
14
+ const hours = Math.floor(minutes / 60);
15
+ if (hours < 24) return `${hours}h`;
16
+ const days = Math.floor(hours / 24);
17
+ if (days < 7) return `${days}d`;
18
+ if (days < 30) return `${Math.floor(days / 7)}w`;
19
+ return new Date(time).toLocaleDateString("en-US", {
20
+ day: "numeric",
21
+ month: "short",
22
+ timeZone: getDashboardTimeZone(),
23
+ });
24
+ }
@@ -2,16 +2,21 @@ import type { ConversationReportEvent } from "@sentry/junior/api/schema";
2
2
 
3
3
  /**
4
4
  * Build the dashboard route for a conversation id. A `turnId` opens the
5
- * start of that Turn in the event log.
5
+ * start of that Turn in the event log. `reviewItems` opens the Review items
6
+ * panel.
6
7
  */
7
8
  export function conversationPath(
8
9
  conversationId: string,
9
- options?: { turnId?: string },
10
+ options?: { reviewItems?: boolean; turnId?: string },
10
11
  ): string {
11
12
  const path = `/conversations/${encodeURIComponent(conversationId)}`;
12
- return options?.turnId
13
- ? `${path}?${CONVERSATION_TURN_PARAM}=${encodeURIComponent(options.turnId)}`
14
- : path;
13
+ const params = new URLSearchParams();
14
+ if (options?.turnId) params.set(CONVERSATION_TURN_PARAM, options.turnId);
15
+ if (options?.reviewItems) {
16
+ params.set(CONVERSATION_REVIEW_PARAM, CONVERSATION_REVIEW_OPEN);
17
+ }
18
+ const search = params.toString();
19
+ return search ? `${path}?${search}` : path;
15
20
  }
16
21
 
17
22
  /** Dashboard home route. Legacy `/conversations/new` redirects here. */
@@ -32,6 +37,10 @@ export function parseConversationEventSeq(
32
37
  return value && /^\d+$/.test(value) ? Number(value) : undefined;
33
38
  }
34
39
 
40
+ /** Query param that opens the Review items panel of a Conversation. */
41
+ export const CONVERSATION_REVIEW_PARAM = "review";
42
+ export const CONVERSATION_REVIEW_OPEN = "open";
43
+
35
44
  /** Query param that opens the start of one Turn in the event log. */
36
45
  export const CONVERSATION_TURN_PARAM = "turn";
37
46
 
@@ -7,21 +7,13 @@ export type ConversationSection = {
7
7
  };
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000;
10
- const SECTION_ORDER = [
11
- "attention",
12
- "priority",
13
- "today",
14
- "yesterday",
15
- "last-week",
16
- "2-weeks",
17
- "3-weeks",
18
- "older",
19
- ] as const;
20
10
 
21
11
  /**
22
- * Group conversations into progressively broader activity sections.
12
+ * Group conversations by last activity: Today, Yesterday, Previous 7 days,
13
+ * Previous 30 days, then one section per month.
23
14
  *
24
- * Conversations in `attentionIds` go to the first section, before Priority.
15
+ * Conversations in `attentionIds` lead their section, so unread and waiting
16
+ * rows sit above read rows without leaving their time group.
25
17
  */
26
18
  export function buildConversationSections(
27
19
  conversations: Conversation[],
@@ -32,16 +24,21 @@ export function buildConversationSections(
32
24
  },
33
25
  ): ConversationSection[] {
34
26
  const nowDay = calendarDay(options.nowMs, options.timeZone);
35
- const sections = new Map<string, ConversationSection>();
36
- const sorted = [...conversations].sort(
27
+ const sections = new Map<string, ConversationSection & { order: number }>();
28
+ const byActivity = [...conversations].sort(
37
29
  (left, right) => activityTime(right) - activityTime(left),
38
30
  );
31
+ const sorted = options.attentionIds
32
+ ? attentionFirst(byActivity, options.attentionIds)
33
+ : byActivity;
39
34
 
40
35
  for (const conversation of sorted) {
41
- const time = activityTime(conversation);
42
- const section = options.attentionIds?.has(conversation.id)
43
- ? { key: "attention", label: "Needs attention" }
44
- : conversationSection(conversation, time, nowDay, options.timeZone);
36
+ const section = conversationSection(
37
+ activityTime(conversation),
38
+ nowDay,
39
+ options.nowMs,
40
+ options.timeZone,
41
+ );
45
42
  const existing = sections.get(section.key);
46
43
  if (existing) {
47
44
  existing.conversations.push(conversation);
@@ -50,9 +47,9 @@ export function buildConversationSections(
50
47
  }
51
48
  }
52
49
 
53
- return [...sections.values()].sort(
54
- (left, right) => sectionRank(left.key) - sectionRank(right.key),
55
- );
50
+ return [...sections.values()]
51
+ .sort((left, right) => left.order - right.order)
52
+ .map(({ order: _order, ...section }) => section);
56
53
  }
57
54
 
58
55
  /** Join the conversations that wait on the viewer or finished unseen. */
@@ -82,44 +79,61 @@ function activityTime(conversation: Conversation): number {
82
79
  return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
83
80
  }
84
81
 
82
+ /**
83
+ * Pick the section for an activity time. `order` sorts sections newest
84
+ * first.
85
+ */
85
86
  function conversationSection(
86
- conversation: Conversation,
87
87
  time: number,
88
88
  nowDay: number,
89
+ nowMs: number,
89
90
  timeZone: string,
90
- ): Pick<ConversationSection, "key" | "label"> {
91
- if (!Number.isFinite(time)) return { key: "older", label: "Older" };
92
-
93
- // Priority membership is decided by the conversation feed API.
94
- if (conversation.isPriority) {
95
- return { key: "priority", label: "Priority" };
91
+ ): Pick<ConversationSection, "key" | "label"> & { order: number } {
92
+ if (!Number.isFinite(time)) {
93
+ return { key: "older", label: "Older", order: Number.POSITIVE_INFINITY };
96
94
  }
97
95
 
98
- const day = calendarDay(time, timeZone);
99
- const ageInDays = Math.max(0, Math.floor((nowDay - day) / DAY_MS));
100
- if (ageInDays === 0) return { key: "today", label: "Today" };
101
- if (ageInDays === 1) return { key: "yesterday", label: "Yesterday" };
102
- if (ageInDays < 7) {
103
- return {
104
- key: `day-${day}`,
105
- label: new Intl.DateTimeFormat("en-US", {
106
- timeZone,
107
- weekday: "long",
108
- }).format(time),
109
- };
96
+ const ageInDays = Math.max(
97
+ 0,
98
+ Math.round((nowDay - calendarDay(time, timeZone)) / DAY_MS),
99
+ );
100
+ if (ageInDays === 0) return { key: "today", label: "Today", order: 0 };
101
+ if (ageInDays === 1) {
102
+ return { key: "yesterday", label: "Yesterday", order: 1 };
103
+ }
104
+ if (ageInDays <= 7) {
105
+ return { key: "previous-7-days", label: "Previous 7 days", order: 2 };
110
106
  }
111
- if (ageInDays < 14) return { key: "last-week", label: "Last week" };
112
- if (ageInDays < 21) return { key: "2-weeks", label: "2 weeks ago" };
113
- if (ageInDays < 28) return { key: "3-weeks", label: "3 weeks ago" };
114
- return { key: "older", label: "Older" };
107
+ if (ageInDays <= 30) {
108
+ return { key: "previous-30-days", label: "Previous 30 days", order: 3 };
109
+ }
110
+
111
+ const month = yearMonth(time, timeZone);
112
+ const nowMonth = yearMonth(nowMs, timeZone);
113
+ return {
114
+ key: `month-${month.year}-${month.month}`,
115
+ label: new Intl.DateTimeFormat("en-US", {
116
+ month: "long",
117
+ timeZone,
118
+ ...(month.year === nowMonth.year ? undefined : { year: "numeric" }),
119
+ }).format(time),
120
+ // Month sections follow the rolling windows, newest month first.
121
+ order:
122
+ 4 + (nowMonth.year - month.year) * 12 + (nowMonth.month - month.month),
123
+ };
115
124
  }
116
125
 
117
- function sectionRank(key: string): number {
118
- const known = SECTION_ORDER.indexOf(key as (typeof SECTION_ORDER)[number]);
119
- if (known >= 0) return known;
120
- // Weekday buckets sit between Yesterday and Last week.
121
- if (key.startsWith("day-")) return SECTION_ORDER.indexOf("yesterday") + 0.5;
122
- return SECTION_ORDER.length;
126
+ function yearMonth(
127
+ time: number,
128
+ timeZone: string,
129
+ ): { month: number; year: number } {
130
+ const parts = new Intl.DateTimeFormat("en-US", {
131
+ month: "numeric",
132
+ timeZone,
133
+ year: "numeric",
134
+ }).formatToParts(time);
135
+ const values = new Map(parts.map((part) => [part.type, Number(part.value)]));
136
+ return { month: values.get("month") ?? 1, year: values.get("year") ?? 0 };
123
137
  }
124
138
 
125
139
  function calendarDay(time: number, timeZone: string): number {