@sentry/junior-dashboard 0.294.0 → 0.295.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 (93) hide show
  1. package/dist/app.js +7 -5
  2. package/dist/client-manifest.json +1 -1
  3. package/dist/index.js +7 -5
  4. package/dist/public/chunks/{AdminPage-mn71yp2v.js → AdminPage-lctmhvpg.js} +1 -1
  5. package/dist/public/chunks/{AnchorHeading-cp3vx8qa.js → AnchorHeading-ixbupfij.js} +1 -1
  6. package/dist/public/chunks/{Area-h08sxa33.js → Area-d95rfkv5.js} +1 -1
  7. package/dist/public/chunks/AutomationPage-m78li1hv.js +3 -0
  8. package/dist/public/chunks/{AutomationRunDetails-jjdhbqtn.js → AutomationRunDetails-dx5lutte.js} +1 -1
  9. package/dist/public/chunks/{AutomationRunsPage-j50ps7nt.js → AutomationRunsPage-cba6nbxd.js} +1 -1
  10. package/dist/public/chunks/{AutomationStatus-czfg6ayl.js → AutomationStatus-eqq5lrad.js} +1 -1
  11. package/dist/public/chunks/AutomationsPage-cp7wtt2v.js +8 -0
  12. package/dist/public/chunks/{BarChart-e3oi6in1.js → BarChart-lbkezxbi.js} +1 -1
  13. package/dist/public/chunks/{BoardPage-f0uxqg7u.js → BoardPage-m4zqf62q.js} +1 -1
  14. package/dist/public/chunks/{CodeChangeRow-dk3gkjut.js → CodeChangeRow-d620nk75.js} +2 -2
  15. package/dist/public/chunks/CodePage-h2ayars2.js +1 -0
  16. package/dist/public/chunks/{CodeSummaryCards-o1po2jis.js → CodeSummaryCards-iotfblbp.js} +1 -1
  17. package/dist/public/chunks/{ConnectionsPage-mholegdk.js → ConnectionsPage-nm7w1i05.js} +2 -2
  18. package/dist/public/chunks/{DirectoryMetric-dgzufji4.js → DirectoryMetric-huy2ptzz.js} +1 -1
  19. package/dist/public/chunks/{DrawerContent-ovjgr16m.js → DrawerContent-n3biwp5m.js} +1 -1
  20. package/dist/public/chunks/{FilterBar-mtnh7fbi.js → FilterBar-dqnt967g.js} +1 -1
  21. package/dist/public/chunks/FormNotice-kj5pzsl7.js +1 -0
  22. package/dist/public/chunks/{LocationDetailPage-ggd6zgzp.js → LocationDetailPage-iqtx76w6.js} +2 -2
  23. package/dist/public/chunks/{LocationsPage-fh6vzxa1.js → LocationsPage-ju5jfejt.js} +2 -2
  24. package/dist/public/chunks/{McpSettingsPage-fm3fh2ux.js → McpSettingsPage-brav5c42.js} +1 -1
  25. package/dist/public/chunks/{PeoplePage-odrbcmhj.js → PeoplePage-lfq8nrto.js} +2 -2
  26. package/dist/public/chunks/{PersonProfilePage-gynexe89.js → PersonProfilePage-ep45sxr6.js} +1 -1
  27. package/dist/public/chunks/{PersonalTokensPage-4o4zj02w.js → PersonalTokensPage-jbt4q7uz.js} +1 -1
  28. package/dist/public/chunks/PluginReportRecordSet-ml18z19r.js +1 -0
  29. package/dist/public/chunks/{PluginUserPage-kwylbbpr.js → PluginUserPage-ilar25ic.js} +2 -2
  30. package/dist/public/chunks/RepositoryPage-o9m3vty4.js +1 -0
  31. package/dist/public/chunks/SettingsPage-jc9et1qq.js +1 -0
  32. package/dist/public/chunks/SystemPage-bbp8cdds.js +15 -0
  33. package/dist/public/chunks/{WorkspaceFormPage-hwvyvnuk.js → WorkspaceFormPage-kshmsdek.js} +2 -2
  34. package/dist/public/chunks/{WorkspacesPage-lu9w3ugz.js → WorkspacesPage-ohasc10u.js} +1 -1
  35. package/dist/public/chunks/alert-ifqo1uj7.js +1 -0
  36. package/dist/public/chunks/{automationFormat-hd7pnocr.js → automationFormat-kv314fzh.js} +1 -1
  37. package/dist/public/chunks/{chart-myr1oeen.js → chart-il2ahs5y.js} +1 -1
  38. package/dist/public/chunks/{circle-dollar-sign-fpl4dmok.js → circle-dollar-sign-h7k4kr7s.js} +1 -1
  39. package/dist/public/chunks/{code-highlighter-i3ux67or.js → code-highlighter-ea4j0ys1.js} +1 -1
  40. package/dist/public/chunks/{coins-bqkaxeba.js → coins-l2c8c17w.js} +1 -1
  41. package/dist/public/chunks/{format-gle1s0ri.js → format-flkutk9e.js} +2 -2
  42. package/dist/public/chunks/junior-g62bxb2t.js +4 -0
  43. package/dist/public/chunks/{native-select-jfknh8t4.js → native-select-fxs3p0bj.js} +1 -1
  44. package/dist/public/chunks/{plus-gndielka.js → plus-54e3zzys.js} +1 -1
  45. package/dist/public/chunks/vendor-icons-blmhaswc.js +698 -0
  46. package/dist/public/chunks/{workspaceQueries-m1ksz95c.js → workspaceQueries-onxawhg4.js} +1 -1
  47. package/dist/public/client-ctcthgam.js +165 -0
  48. package/dist/public/styles-4CIT7L7K.css +2 -0
  49. package/dist/tailwind.css +1 -1
  50. package/package.json +4 -4
  51. package/src/client/api.ts +16 -0
  52. package/src/client/components/MarkdownViewToggle.tsx +24 -0
  53. package/src/client/components/SkillLink.tsx +119 -0
  54. package/src/client/conversations/ConversationHomeList.tsx +87 -76
  55. package/src/client/conversations/ConversationListStatusIcon.tsx +39 -55
  56. package/src/client/conversations/ConversationSectionList.tsx +101 -0
  57. package/src/client/conversations/ConversationSidebar.tsx +159 -169
  58. package/src/client/conversations/ConversationWorkspace.tsx +5 -16
  59. package/src/client/conversations/TextAttachmentPreview.tsx +5 -14
  60. package/src/client/conversations/WaitingChip.tsx +78 -0
  61. package/src/client/conversations/conversationSections.ts +78 -55
  62. package/src/client/conversations/heldListOrder.ts +170 -0
  63. package/src/client/conversations/toolCallPreview.ts +3 -1
  64. package/src/client/conversations/transcriptSource.ts +7 -2
  65. package/src/client/conversations/useMarkConversationUnread.ts +2 -2
  66. package/src/client/format.ts +18 -2
  67. package/src/client/pages/automations/AutomationDraftChanges.tsx +2 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +3 -3
  69. package/src/client/pages/automations/AutomationSkillPicker.tsx +75 -31
  70. package/src/client/pages/automations/AutomationTaskFields.tsx +3 -13
  71. package/src/client/pages/automations/AutomationVersionsTab.tsx +2 -5
  72. package/src/client/pages/automations/skillSearch.ts +50 -0
  73. package/src/client/pages/code/RepositorySections.tsx +14 -5
  74. package/src/client/pages/code/RepositoryTabs.tsx +5 -11
  75. package/src/client/pages/system/PluginDetails.tsx +15 -8
  76. package/src/client/pages/system/SkillInventory.tsx +2 -6
  77. package/src/client/review-desk/queries.ts +30 -23
  78. package/src/client/review-desk/reviewItemFormat.ts +3 -1
  79. package/src/client/types.ts +5 -1
  80. package/src/mock-reporting/review-items.ts +6 -2
  81. package/dist/public/chunks/AutomationPage-e1l74ezg.js +0 -10
  82. package/dist/public/chunks/AutomationsPage-cphxl4l6.js +0 -8
  83. package/dist/public/chunks/CodePage-i97ivyji.js +0 -1
  84. package/dist/public/chunks/FormNotice-eyo2kdte.js +0 -1
  85. package/dist/public/chunks/PluginReportRecordSet-hm1cwp6c.js +0 -1
  86. package/dist/public/chunks/RepositoryPage-fti2vfu4.js +0 -1
  87. package/dist/public/chunks/SettingsPage-kxspx37h.js +0 -1
  88. package/dist/public/chunks/SystemPage-jurblfgy.js +0 -15
  89. package/dist/public/chunks/SystemPlugins-jr37f1cv.js +0 -1
  90. package/dist/public/chunks/junior-hdb4wc4q.js +0 -4
  91. package/dist/public/chunks/vendor-icons-mxz7yi04.js +0 -692
  92. package/dist/public/client-f7asutg5.js +0 -165
  93. package/dist/public/styles-ZJJGBKKX.css +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-dashboard",
3
- "version": "0.294.0",
3
+ "version": "0.295.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -45,10 +45,10 @@
45
45
  "remark-gfm": "^4.0.1",
46
46
  "shiki": "4.1.0",
47
47
  "zod": "^4.5.4",
48
- "@sentry/junior-plugin-api": "0.294.0"
48
+ "@sentry/junior-plugin-api": "0.295.0"
49
49
  },
50
50
  "peerDependencies": {
51
- "@sentry/junior": "0.294.0"
51
+ "@sentry/junior": "0.295.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@fontsource-variable/inter": "^5.3.0",
@@ -72,7 +72,7 @@
72
72
  "typescript": "^7.0.2",
73
73
  "unrun": "^0.3.1",
74
74
  "vitest": "^4.1.12",
75
- "@sentry/junior": "0.294.0"
75
+ "@sentry/junior": "0.295.0"
76
76
  },
77
77
  "scripts": {
78
78
  "build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:css && pnpm run build:client && pnpm run build:server",
package/src/client/api.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import {
31
31
  pluginOperationalReportFeedSchema,
32
32
  pluginsSchema,
33
+ skillDetailReportSchema,
33
34
  skillReportsSchema,
34
35
  } from "@sentry/junior/api/schema";
35
36
  import { pluginUserPageLinksSchema } from "@sentry/junior-plugin-api";
@@ -388,6 +389,21 @@ export function useSkillsData() {
388
389
  });
389
390
  }
390
391
 
392
+ /** Fetch one skill with its SKILL.md body. */
393
+ export function useSkillDetail(name: string) {
394
+ return useQuery({
395
+ queryKey: ["dashboard", "skills", name],
396
+ queryFn: ({ signal }) =>
397
+ fetchDashboardJson(
398
+ skillDetailReportSchema,
399
+ `/api/skills/${encodeURIComponent(name)}`,
400
+ signal,
401
+ ),
402
+ retry: false,
403
+ staleTime: dashboardMetadataStaleTimeMs,
404
+ });
405
+ }
406
+
391
407
  /** Fetch operational plugin reports used by System navigation and pages. */
392
408
  export function usePluginReportsData() {
393
409
  return useQuery({
@@ -0,0 +1,24 @@
1
+ import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
2
+
3
+ /** How a drawer shows Markdown text. */
4
+ export type MarkdownView = "formatted" | "raw";
5
+
6
+ /** Switch a Markdown preview between formatted and raw text. */
7
+ export function MarkdownViewToggle(props: {
8
+ onChange(view: MarkdownView): void;
9
+ value: MarkdownView;
10
+ }) {
11
+ return (
12
+ <ToggleGroup
13
+ aria-label="Markdown view"
14
+ onValueChange={(values) => {
15
+ const next = values[0];
16
+ if (next) props.onChange(next);
17
+ }}
18
+ value={[props.value]}
19
+ >
20
+ <ToggleGroupItem value="formatted">Formatted</ToggleGroupItem>
21
+ <ToggleGroupItem value="raw">Raw</ToggleGroupItem>
22
+ </ToggleGroup>
23
+ );
24
+ }
@@ -0,0 +1,119 @@
1
+ import { useId, useState, type ReactNode } from "react";
2
+
3
+ import { useSkillDetail } from "../api";
4
+ import { HighlightedCode } from "../code";
5
+ import { DisplayText } from "../display-text/DisplayText";
6
+ import { DashboardApiError } from "../http";
7
+ import { cn } from "../styles";
8
+ import { Drawer } from "./Drawer";
9
+ import { InlineError } from "./InlineError";
10
+ import { MarkdownViewToggle, type MarkdownView } from "./MarkdownViewToggle";
11
+ import { Skeleton } from "./ui/skeleton";
12
+
13
+ const SKELETON_LINE_WIDTHS = ["w-3/4", "w-full", "w-5/6", "w-2/3", "w-full"];
14
+
15
+ /** Write a skill name the way people invoke it, such as `$self-update`. */
16
+ export function skillLabel(name: string): string {
17
+ return `$${name}`;
18
+ }
19
+
20
+ /**
21
+ * Name a skill as `$skill-name` and open its SKILL.md in a drawer on click.
22
+ * Pass children to replace the label, such as "View skill". Set `chip` in
23
+ * skill lists.
24
+ */
25
+ export function SkillLink(props: {
26
+ children?: ReactNode;
27
+ chip?: boolean;
28
+ name: string;
29
+ }) {
30
+ const [open, setOpen] = useState(false);
31
+ return (
32
+ <>
33
+ <button
34
+ className={cn(
35
+ "cursor-pointer text-dashboard-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus",
36
+ props.chip
37
+ ? "rounded-md border border-dashboard-border bg-dashboard-overlay-soft px-2.5 py-1.5 text-xs hover:border-dashboard-border-heavy"
38
+ : "border-0 bg-transparent p-0 hover:underline",
39
+ props.children ? "font-sans" : "font-mono",
40
+ )}
41
+ onClick={() => setOpen(true)}
42
+ type="button"
43
+ >
44
+ {props.children ?? skillLabel(props.name)}
45
+ </button>
46
+ {open ? (
47
+ <SkillDrawer name={props.name} onClose={() => setOpen(false)} />
48
+ ) : null}
49
+ </>
50
+ );
51
+ }
52
+
53
+ function SkillDrawer(props: { name: string; onClose(): void }) {
54
+ const titleId = useId();
55
+ const [view, setView] = useState<MarkdownView>("formatted");
56
+ const skill = useSkillDetail(props.name);
57
+ const missing =
58
+ skill.error instanceof DashboardApiError && skill.error.status === 404;
59
+
60
+ return (
61
+ <Drawer
62
+ actions={
63
+ skill.data ? (
64
+ <MarkdownViewToggle onChange={setView} value={view} />
65
+ ) : null
66
+ }
67
+ closeLabel={`Close ${skillLabel(props.name)}`}
68
+ header={
69
+ <div className="min-w-0 [overflow-wrap:anywhere]">
70
+ <h2
71
+ className="m-0 font-mono text-sm font-semibold text-dashboard-text"
72
+ id={titleId}
73
+ >
74
+ {skillLabel(props.name)}
75
+ </h2>
76
+ {skill.data ? (
77
+ <div className="font-sans text-xs text-dashboard-text-muted">
78
+ {skill.data.pluginProvider
79
+ ? `From the ${skill.data.pluginProvider} plugin`
80
+ : "App or local skill"}
81
+ {skill.data.disableModelInvocation ? " · Manual only" : ""}
82
+ </div>
83
+ ) : null}
84
+ </div>
85
+ }
86
+ onClose={props.onClose}
87
+ openKey={titleId}
88
+ titleId={titleId}
89
+ width="wide"
90
+ >
91
+ {skill.isPending ? (
92
+ <div aria-label="Loading skill" className="grid gap-2" role="status">
93
+ {SKELETON_LINE_WIDTHS.map((width, index) => (
94
+ <Skeleton className={`h-4 ${width}`} key={index} />
95
+ ))}
96
+ </div>
97
+ ) : skill.isError ? (
98
+ <InlineError>
99
+ {missing
100
+ ? "This skill is no longer installed."
101
+ : "Could not load this skill."}
102
+ </InlineError>
103
+ ) : (
104
+ <div className="grid min-w-0 gap-4">
105
+ <p className="m-0 text-sm text-dashboard-text-muted">
106
+ {skill.data.description}
107
+ </p>
108
+ <div className="border-t border-dashboard-border-subtle pt-4">
109
+ {view === "formatted" ? (
110
+ <DisplayText document text={skill.data.body} />
111
+ ) : (
112
+ <HighlightedCode code={skill.data.body} language="markdown" />
113
+ )}
114
+ </div>
115
+ </div>
116
+ )}
117
+ </Drawer>
118
+ );
119
+ }
@@ -20,24 +20,24 @@ import {
20
20
  ParticipantAvatarStack,
21
21
  } from "../components/ParticipantAvatarStack";
22
22
  import { cn } from "../styles";
23
+ import type { WaitingConversation } from "../review-desk/queries";
23
24
  import type { Conversation } from "../types";
24
25
  import { useConversationActionToasts } from "./conversationActionToasts";
25
26
  import { formatActivityDay } from "./conversationAge";
26
27
  import { conversationCardTags } from "./ConversationContextTags";
27
- import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
28
+ import {
29
+ ConversationListStatusIcon,
30
+ ConversationStatusText,
31
+ } from "./ConversationListStatusIcon";
28
32
  import { conversationPath } from "./conversationRoutes";
29
33
  import { useArchiveConversation } from "./conversationArchive";
30
34
  import { DisplayText } from "../display-text/DisplayText";
31
- import {
32
- attentionConversationIds,
33
- buildConversationSections,
34
- type ConversationSection,
35
- } from "./conversationSections";
35
+ import type { ConversationSection } from "./conversationSections";
36
+ import { ConversationSectionList } from "./ConversationSectionList";
36
37
  import { ConversationSectionHeading } from "./ConversationSectionHeading";
37
38
  import { usePrefetchConversation } from "./conversationPrefetch";
38
39
  import { useCollapsedConversationSections } from "./useCollapsedConversationSections";
39
40
  import {
40
- PinnedConversationOrder,
41
41
  usePinnedRowDrag,
42
42
  type PinnedRowDrag,
43
43
  } from "./PinnedConversationOrder";
@@ -51,7 +51,7 @@ export function ConversationHomeList(props: {
51
51
  /** Leave unset when the list shows only some pins. */
52
52
  onReorderPins?: ReorderPins;
53
53
  timeZone: string;
54
- waitingConversationIds: ReadonlySet<string>;
54
+ waitingConversations: ReadonlyMap<string, WaitingConversation>;
55
55
  }) {
56
56
  const { collapsedKeys, toggle: toggleSection } =
57
57
  useCollapsedConversationSections();
@@ -63,49 +63,48 @@ export function ConversationHomeList(props: {
63
63
  [showActionError],
64
64
  );
65
65
 
66
- if (props.loading && props.conversations.length === 0) {
67
- return <ConversationHomeListLoading label="Loading conversations" />;
68
- }
69
-
70
- if (props.conversations.length === 0) {
71
- return (
72
- <div className="rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint p-4">
73
- <EmptyTelemetry>
74
- {props.emptyLabel ?? "No conversations match this view."}
75
- </EmptyTelemetry>
76
- </div>
77
- );
78
- }
79
-
80
- const sections = buildConversationSections(props.conversations, {
81
- attentionIds: attentionConversationIds(
82
- props.conversations,
83
- props.waitingConversationIds,
84
- ),
85
- nowMs: Date.now(),
86
- timeZone: props.timeZone,
87
- });
88
- const pinnedSection = sections.find((section) => section.key === "pinned");
66
+ // The list stays mounted through the first load, so a pointer that rests
67
+ // on it then still holds the order.
89
68
  return (
90
- <PinnedConversationOrder
91
- conversations={pinnedSection?.conversations ?? []}
92
- onError={handleReorderError}
93
- reorder={props.onReorderPins}
69
+ <ConversationSectionList
70
+ conversations={props.conversations}
71
+ loading={props.loading ?? false}
72
+ onReorderError={handleReorderError}
73
+ onReorderPins={props.onReorderPins}
74
+ timeZone={props.timeZone}
75
+ waitingConversations={props.waitingConversations}
94
76
  >
95
- <div aria-label="Your conversations" className="grid gap-5" role="list">
96
- {sections.map((section) => (
97
- <ConversationCardSection
98
- collapsed={collapsedKeys.has(section.key)}
99
- key={section.key}
100
- onArchiveError={showArchiveError}
101
- onArchived={showArchived}
102
- onToggle={() => toggleSection(section.key)}
103
- waitingConversationIds={props.waitingConversationIds}
104
- section={section}
105
- />
106
- ))}
107
- </div>
108
- </PinnedConversationOrder>
77
+ {({ releaseHold, sections }) =>
78
+ props.loading && props.conversations.length === 0 ? (
79
+ <ConversationHomeListLoading label="Loading conversations" />
80
+ ) : props.conversations.length === 0 ? (
81
+ <div className="rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint p-4">
82
+ <EmptyTelemetry>
83
+ {props.emptyLabel ?? "No conversations match this view."}
84
+ </EmptyTelemetry>
85
+ </div>
86
+ ) : (
87
+ <div
88
+ aria-label="Your conversations"
89
+ className="grid gap-5"
90
+ role="list"
91
+ >
92
+ {sections.map((section) => (
93
+ <ConversationCardSection
94
+ collapsed={collapsedKeys.has(section.key)}
95
+ key={section.key}
96
+ onArchiveError={showArchiveError}
97
+ onArchived={showArchived}
98
+ onRowAction={releaseHold}
99
+ onToggle={() => toggleSection(section.key)}
100
+ waitingConversations={props.waitingConversations}
101
+ section={section}
102
+ />
103
+ ))}
104
+ </div>
105
+ )
106
+ }
107
+ </ConversationSectionList>
109
108
  );
110
109
  }
111
110
 
@@ -176,9 +175,10 @@ function ConversationCardSection(props: {
176
175
  collapsed: boolean;
177
176
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
178
177
  onArchived(conversation: Conversation): void;
178
+ onRowAction(): void;
179
179
  onToggle(): void;
180
180
  section: ConversationSection;
181
- waitingConversationIds: ReadonlySet<string>;
181
+ waitingConversations: ReadonlyMap<string, WaitingConversation>;
182
182
  }) {
183
183
  return (
184
184
  <section aria-labelledby={`conversation-section-${props.section.key}`}>
@@ -202,9 +202,8 @@ function ConversationCardSection(props: {
202
202
  conversation,
203
203
  onArchiveError: props.onArchiveError,
204
204
  onArchived: props.onArchived,
205
- waitingOnViewer: props.waitingConversationIds.has(
206
- conversation.id,
207
- ),
205
+ onRowAction: props.onRowAction,
206
+ waitingOnViewer: props.waitingConversations.has(conversation.id),
208
207
  };
209
208
  return conversation.pinnedAt ? (
210
209
  <PinnedConversationCard {...card} key={conversation.id} />
@@ -224,6 +223,8 @@ type ConversationCardProps = {
224
223
  drag?: PinnedRowDrag;
225
224
  onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
226
225
  onArchived(conversation: Conversation): void;
226
+ /** Lets the list show the result of a row action at once. */
227
+ onRowAction(): void;
227
228
  waitingOnViewer: boolean;
228
229
  };
229
230
 
@@ -250,13 +251,11 @@ function ConversationCard(props: ConversationCardProps) {
250
251
  const prefetch = usePrefetchConversation(conversation.id);
251
252
  const VisibilityIcon = isPrivate ? LockKeyhole : MessageSquare;
252
253
  const unread = Boolean(conversation.unread);
253
- // Idle and stopped conversations get no badge, so the badges that show
254
- // stand out.
255
- const showStatus =
256
- props.waitingOnViewer ||
257
- unread ||
258
- status === "active" ||
259
- status === "failed";
254
+ const emphasized = props.waitingOnViewer || unread;
255
+ // Only read state gets a badge. A running card shows its spinner in place
256
+ // of the time, and failed and stopped are text beside the time.
257
+ const showStatus = emphasized && status !== "active";
258
+ const activityAt = conversation.lastMessageAt ?? conversation.lastSeenAt;
260
259
  return (
261
260
  <article
262
261
  {...prefetch}
@@ -292,8 +291,6 @@ function ConversationCard(props: ConversationCardProps) {
292
291
  {showStatus ? (
293
292
  <span className="absolute -bottom-1 -right-1 grid size-3.5 place-items-center rounded-full bg-dashboard-surface-panel">
294
293
  <ConversationListStatusIcon
295
- // The tile already shows the lock.
296
- isPrivate={false}
297
294
  status={status}
298
295
  unread={unread}
299
296
  waitingOnViewer={props.waitingOnViewer}
@@ -303,7 +300,12 @@ function ConversationCard(props: ConversationCardProps) {
303
300
  </span>
304
301
  <div className="grid min-w-0">
305
302
  <div className="flex min-w-0 items-center gap-3">
306
- <h4 className="pointer-events-none relative z-[1] m-0 min-w-0 flex-1 truncate font-display text-sm font-medium leading-6 text-dashboard-text">
303
+ <h4
304
+ className={cn(
305
+ "pointer-events-none relative z-[1] m-0 min-w-0 flex-1 truncate font-display text-sm leading-6 text-dashboard-text",
306
+ emphasized ? "font-semibold" : "font-medium",
307
+ )}
308
+ >
307
309
  {title}
308
310
  </h4>
309
311
  {participants.length > 0 ? (
@@ -321,27 +323,36 @@ function ConversationCard(props: ConversationCardProps) {
321
323
  />
322
324
  </span>
323
325
  ) : (
324
- <time
325
- className="pointer-events-none relative z-[1] min-w-14 whitespace-nowrap text-right font-sans text-xs tabular-nums text-dashboard-text-muted"
326
- dateTime={conversation.lastSeenAt}
327
- title={formatTime(conversation.lastSeenAt, {
328
- dateStyle: "medium",
329
- timeStyle: "short",
330
- })}
331
- >
332
- {formatActivityDay(conversation.lastSeenAt)}
333
- </time>
326
+ <>
327
+ {/* The tile already shows the lock. */}
328
+ <ConversationStatusText
329
+ className="pointer-events-none relative z-[1]"
330
+ isPrivate={false}
331
+ status={status}
332
+ />
333
+ <time
334
+ className="pointer-events-none relative z-[1] min-w-14 whitespace-nowrap text-right font-sans text-xs tabular-nums text-dashboard-text-muted"
335
+ dateTime={activityAt}
336
+ title={formatTime(activityAt, {
337
+ dateStyle: "medium",
338
+ timeStyle: "short",
339
+ })}
340
+ >
341
+ {formatActivityDay(activityAt)}
342
+ </time>
343
+ </>
334
344
  )}
335
345
  <button
336
346
  aria-label={`${conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
337
347
  className="relative z-[1] shrink-0 cursor-pointer border-0 bg-transparent p-0 text-dashboard-text-muted transition before:absolute before:-inset-2 hover:text-dashboard-text focus:outline-none focus:ring-2 focus:ring-dashboard-focus focus:ring-offset-2 focus:ring-offset-dashboard-canvas disabled:cursor-not-allowed disabled:opacity-50"
338
348
  disabled={archive.isPending}
339
- onClick={() =>
349
+ onClick={() => {
350
+ props.onRowAction();
340
351
  archive.mutate({
341
352
  archived: !conversation.archivedAt,
342
353
  lastSeenAt: conversation.lastSeenAt,
343
- })
344
- }
354
+ });
355
+ }}
345
356
  type="button"
346
357
  >
347
358
  {conversation.archivedAt ? (
@@ -5,78 +5,62 @@ import { cn } from "../styles";
5
5
  import type { VisualStatus } from "../types";
6
6
 
7
7
  /**
8
- * Render a conversation status marker. "Waiting on you" replaces every other
9
- * marker. A running conversation shows a spinner, so it never looks like an
10
- * ended one.
8
+ * Render the row marker. Rows use three channels: the section for position,
9
+ * a dot with a bold title for read state, and a spinner for a running Turn.
10
+ * Waiting rows use the same dot as unread rows; their section and chip say
11
+ * why. Private, failed, and stopped go in `ConversationStatusText`.
11
12
  */
12
13
  export function ConversationListStatusIcon(props: {
13
- isPrivate: boolean;
14
14
  status: VisualStatus;
15
15
  unread: boolean;
16
16
  waitingOnViewer: boolean;
17
17
  }) {
18
- if (props.waitingOnViewer) {
19
- return (
20
- <span
21
- aria-label="Waiting on you"
22
- className="size-2 shrink-0 rounded-full bg-violet-300 shadow-[0_0_6px_rgba(196,181,253,0.7)]"
23
- role="img"
24
- />
25
- );
26
- }
27
-
28
18
  if (props.status === "active") {
29
19
  return <Spinner aria-label="Running" className="size-3 text-emerald-300" />;
30
20
  }
31
-
32
- if (props.unread) {
33
- return (
34
- <span
35
- aria-label="Unread"
36
- className="size-1.5 shrink-0 rounded-full bg-orange-300"
37
- role="img"
38
- />
39
- );
40
- }
41
-
42
- if (props.isPrivate) {
43
- return (
44
- <LockKeyhole
45
- aria-label="Private conversation"
46
- className={cn(
47
- "size-3 shrink-0",
48
- props.status === "failed" && "text-rose-300",
49
- (props.status === "idle" || props.status === "stopped") &&
50
- "text-dashboard-text-muted",
51
- )}
52
- />
53
- );
54
- }
55
-
56
- if (props.status === "stopped") {
57
- return (
58
- <span
59
- aria-label="Stopped"
60
- className="size-1.5 shrink-0 bg-dashboard-text-muted"
61
- role="img"
62
- />
63
- );
64
- }
65
-
66
- if (props.status === "failed") {
21
+ if (props.unread || props.waitingOnViewer) {
67
22
  return (
68
23
  <span
69
- aria-label="Failed"
70
- className="size-1.5 shrink-0 rounded-full bg-rose-300"
24
+ aria-label={props.waitingOnViewer ? "Waiting on you" : "Unread"}
25
+ className="size-1.5 shrink-0 rounded-full bg-dashboard-accent"
71
26
  role="img"
72
27
  />
73
28
  );
74
29
  }
30
+ return null;
31
+ }
75
32
 
33
+ /** Secondary row text for a private, failed, or stopped conversation. */
34
+ export function ConversationStatusText(props: {
35
+ className?: string;
36
+ isPrivate: boolean;
37
+ status: VisualStatus;
38
+ }) {
39
+ if (
40
+ !props.isPrivate &&
41
+ props.status !== "failed" &&
42
+ props.status !== "stopped"
43
+ )
44
+ return null;
76
45
  return (
77
46
  <span
78
- aria-hidden="true"
79
- className="size-1.5 shrink-0 rounded-full bg-dashboard-text-faint"
80
- />
47
+ className={cn(
48
+ "inline-flex shrink-0 items-center gap-1 font-sans text-xs",
49
+ props.className,
50
+ )}
51
+ >
52
+ {props.isPrivate ? (
53
+ <LockKeyhole
54
+ aria-label="Private conversation"
55
+ className="size-3 shrink-0 text-dashboard-text-faint"
56
+ role="img"
57
+ />
58
+ ) : null}
59
+ {props.status === "failed" ? (
60
+ <span className="text-rose-400">Failed</span>
61
+ ) : props.status === "stopped" ? (
62
+ <span className="text-dashboard-text-muted">Stopped</span>
63
+ ) : null}
64
+ </span>
81
65
  );
82
66
  }
@@ -0,0 +1,101 @@
1
+ import { useCallback, useMemo, type ReactNode } from "react";
2
+ import { ArrowUp } from "lucide-react";
3
+
4
+ import {
5
+ useWaitingConversationsSettled,
6
+ type WaitingConversation,
7
+ } from "../review-desk/queries";
8
+ import type { Conversation } from "../types";
9
+ import {
10
+ buildConversationSections,
11
+ type ConversationSection,
12
+ } from "./conversationSections";
13
+ import { useHeldListOrder } from "./heldListOrder";
14
+ import { PinnedConversationOrder } from "./PinnedConversationOrder";
15
+ import type { ReorderPins } from "./useReorderPinnedConversations";
16
+
17
+ /**
18
+ * Lay out a conversation list in its sections and hold the order while the
19
+ * reader points at it or works in it. The sidebar and the home list render
20
+ * their rows inside this, so both group, sort, and hold the same way. Row
21
+ * actions call `releaseHold`, so their result shows at once.
22
+ */
23
+ export function ConversationSectionList(props: {
24
+ children(view: {
25
+ releaseHold(): void;
26
+ sections: ConversationSection[];
27
+ }): ReactNode;
28
+ className?: string;
29
+ conversations: Conversation[];
30
+ loading: boolean;
31
+ onReorderError(conversation: Conversation): void;
32
+ /** Leave unset when the list shows only some pins. */
33
+ onReorderPins?: ReorderPins;
34
+ timeZone: string;
35
+ waitingConversations: ReadonlyMap<string, WaitingConversation>;
36
+ }) {
37
+ // Rebuild sections only when their inputs change. Avoid fresh Date.now()
38
+ // arrays on unrelated parent renders while the reader is scrolling.
39
+ const latestSections = useMemo(
40
+ () =>
41
+ buildConversationSections(props.conversations, {
42
+ nowMs: Date.now(),
43
+ timeZone: props.timeZone,
44
+ waiting: props.waitingConversations,
45
+ }),
46
+ [props.conversations, props.timeZone, props.waitingConversations],
47
+ );
48
+ const waitingSettled = useWaitingConversationsSettled();
49
+ const held = useHeldListOrder(latestSections, {
50
+ settled: !props.loading && waitingSettled,
51
+ });
52
+ const { releaseHold } = held;
53
+ const { onReorderPins } = props;
54
+ const reorderPins = useCallback<ReorderPins>(
55
+ (move) => {
56
+ releaseHold();
57
+ onReorderPins?.(move);
58
+ },
59
+ [onReorderPins, releaseHold],
60
+ );
61
+ const pinned =
62
+ held.sections.find((section) => section.key === "pinned")?.conversations ??
63
+ [];
64
+ return (
65
+ <div {...held.holdProps} className={props.className}>
66
+ <ListUpdatesPill count={held.pending} onApply={held.applyPending} />
67
+ <PinnedConversationOrder
68
+ conversations={pinned}
69
+ onError={props.onReorderError}
70
+ reorder={onReorderPins ? reorderPins : undefined}
71
+ >
72
+ {props.children({ releaseHold, sections: held.sections })}
73
+ </PinnedConversationOrder>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Offer the order that the held list keeps back. The list holds its order
80
+ * while the reader points at it or works in it; this applies the new order.
81
+ */
82
+ function ListUpdatesPill(props: { count: number; onApply(): void }) {
83
+ return (
84
+ <div
85
+ aria-live="polite"
86
+ // Above the sticky section headings of the home list.
87
+ className="pointer-events-none sticky top-0 z-20 flex h-0 items-start justify-center"
88
+ >
89
+ {props.count > 0 ? (
90
+ <button
91
+ className="pointer-events-auto mt-1 inline-flex cursor-pointer items-center gap-1 rounded-full border border-dashboard-border-strong bg-dashboard-surface-raised px-2.5 py-1 font-sans text-xs font-medium text-dashboard-text shadow-sm duration-150 hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1"
92
+ onClick={props.onApply}
93
+ type="button"
94
+ >
95
+ <ArrowUp aria-hidden="true" className="size-3" />
96
+ {props.count} updated
97
+ </button>
98
+ ) : null}
99
+ </div>
100
+ );
101
+ }