@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
@@ -1,3 +1,4 @@
1
+ import type { WaitingConversation } from "../review-desk/queries";
1
2
  import type { Conversation } from "../types";
2
3
 
3
4
  export type ConversationSection = {
@@ -7,29 +8,35 @@ export type ConversationSection = {
7
8
  };
8
9
 
9
10
  const DAY_MS = 24 * 60 * 60 * 1000;
10
- // Pinned sorts first, then Needs attention, then every time section.
11
+ // Pinned sorts first, then Needs you, then every time section.
11
12
  const PINNED_SECTION = { key: "pinned", label: "Pinned", order: -2 };
12
- const ATTENTION_SECTION = {
13
- key: "attention",
14
- label: "Needs attention",
15
- order: -1,
16
- };
13
+ const NEEDS_YOU_SECTION = { key: "needs-you", label: "Needs you", order: -1 };
14
+
15
+ /** Conversations that wait on the viewer, by id, with their wait start. */
16
+ type Waiting = Pick<
17
+ ReadonlyMap<string, Pick<WaitingConversation, "waitingSince">>,
18
+ "get" | "has"
19
+ >;
17
20
 
18
21
  /**
19
- * Group conversations into Pinned, then Needs attention, then by last
20
- * activity: Today, Yesterday, Previous 7 days, Previous 30 days, then one
21
- * section per month.
22
+ * Group conversations into Pinned, then Needs you, then by last Message:
23
+ * Today, Yesterday, Previous 7 days, Previous 30 days, then one section per
24
+ * month.
22
25
  *
23
- * A pinned conversation shows only in Pinned, even when it needs attention.
24
- * Pinned follows the viewer's order; every other section, Needs attention
25
- * too, lists the newest first.
26
+ * Needs you holds the conversations that wait on the viewer: an open Review
27
+ * item in `waiting`, or a Turn that waits on an authorization. Unread and
28
+ * open work do not move a row. A pinned conversation
29
+ * shows only in Pinned, even when it waits on the viewer. Pinned follows the
30
+ * viewer's order. Needs you lists the longest wait first, so the queue keeps
31
+ * its order while the viewer works it. Every other section lists the newest
32
+ * Message first.
26
33
  */
27
34
  export function buildConversationSections(
28
35
  conversations: Conversation[],
29
36
  options: {
30
- attentionIds: ReadonlySet<string>;
31
37
  nowMs: number;
32
38
  timeZone: string;
39
+ waiting: Waiting;
33
40
  },
34
41
  ): ConversationSection[] {
35
42
  const nowDay = calendarDay(options.nowMs, options.timeZone);
@@ -41,8 +48,8 @@ export function buildConversationSections(
41
48
  for (const conversation of sorted) {
42
49
  const section = conversation.pinnedAt
43
50
  ? PINNED_SECTION
44
- : needsAttention(conversation, options.attentionIds)
45
- ? ATTENTION_SECTION
51
+ : waitsOnViewer(conversation, options.waiting)
52
+ ? NEEDS_YOU_SECTION
46
53
  : conversationSection(
47
54
  activityTime(conversation),
48
55
  nowDay,
@@ -58,61 +65,51 @@ export function buildConversationSections(
58
65
  }
59
66
 
60
67
  sections.get(PINNED_SECTION.key)?.conversations.sort(comparePinOrder);
68
+ sections
69
+ .get(NEEDS_YOU_SECTION.key)
70
+ ?.conversations.sort(compareWaitStart(options.waiting));
61
71
  return [...sections.values()]
62
72
  .sort((left, right) => left.order - right.order)
63
73
  .map(({ order: _order, ...section }) => section);
64
74
  }
65
75
 
66
76
  /**
67
- * True when the conversation waits on a person: its id is in `attentionIds`,
68
- * a Turn waits on a person to finish an authorization, or it has unfinished
69
- * work, such as an open pull request, while Junior is not running in it.
77
+ * True when the conversation waits on the viewer: an open Review item waits
78
+ * on them, or a Turn waits on a person to finish an authorization.
70
79
  */
71
- function needsAttention(
72
- conversation: Conversation,
73
- attentionIds: ReadonlySet<string>,
74
- ): boolean {
80
+ function waitsOnViewer(conversation: Conversation, waiting: Waiting): boolean {
75
81
  return (
76
- attentionIds.has(conversation.id) ||
77
- conversation.awaitingAuthorization === true ||
78
- (conversation.unfinishedWork === true && conversation.status !== "active")
82
+ waiting.has(conversation.id) || conversation.awaitingAuthorization === true
79
83
  );
80
84
  }
81
85
 
82
- /** Join the conversations that wait on the viewer or are unread. */
83
- export function attentionConversationIds(
84
- conversations: readonly Conversation[],
85
- waitingConversationIds: Iterable<string>,
86
- ): ReadonlySet<string> {
87
- return new Set([
88
- ...waitingConversationIds,
89
- ...conversations
90
- .filter((conversation) => conversation.unread)
91
- .map((conversation) => conversation.id),
92
- ]);
93
- }
94
-
95
86
  /**
96
- * Sort pinned, then attention conversations to the front. Pinned follows the
97
- * viewer's order. The sort is stable, so the other groups keep the feed order.
98
- * Paginated lists call this before paging, so page 1 holds every pinned and
99
- * attention conversation.
87
+ * Sort pinned, then waiting conversations to the front, in the order that
88
+ * `buildConversationSections` shows them: pins in the viewer's order, the
89
+ * longest wait first, then the newest Message first. Paginated lists call
90
+ * this before paging, so page 1 holds every pinned and waiting conversation,
91
+ * and pages split where the sections do.
100
92
  */
101
- export function pinnedAndAttentionFirst(
93
+ export function pinnedAndWaitingFirst(
102
94
  conversations: Conversation[],
103
- attentionIds: ReadonlySet<string>,
95
+ waiting: Waiting,
104
96
  ): Conversation[] {
105
97
  const rank = (conversation: Conversation) =>
106
- conversation.pinnedAt
107
- ? 0
108
- : needsAttention(conversation, attentionIds)
109
- ? 1
110
- : 2;
111
- return [...conversations].sort(
112
- (left, right) =>
113
- rank(left) - rank(right) ||
114
- (left.pinnedAt && right.pinnedAt ? comparePinOrder(left, right) : 0),
115
- );
98
+ conversation.pinnedAt ? 0 : waitsOnViewer(conversation, waiting) ? 1 : 2;
99
+ const byWait = compareWaitStart(waiting);
100
+ return [...conversations]
101
+ .sort((left, right) => activityTime(right) - activityTime(left))
102
+ .sort((left, right) => {
103
+ const leftRank = rank(left);
104
+ return (
105
+ leftRank - rank(right) ||
106
+ (leftRank === 0
107
+ ? comparePinOrder(left, right)
108
+ : leftRank === 1
109
+ ? byWait(left, right)
110
+ : 0)
111
+ );
112
+ });
116
113
  }
117
114
 
118
115
  /**
@@ -133,8 +130,34 @@ function pinTime(conversation: Conversation): number {
133
130
  return conversation.pinnedAt ? Date.parse(conversation.pinnedAt) || 0 : 0;
134
131
  }
135
132
 
133
+ /**
134
+ * Order the longest wait first. A conversation with no known wait start
135
+ * follows those that have one, newest first. The stable sort keeps that
136
+ * activity order.
137
+ */
138
+ function compareWaitStart(
139
+ waiting: Waiting,
140
+ ): (left: Conversation, right: Conversation) => number {
141
+ const start = (conversation: Conversation) => {
142
+ const time = Date.parse(waiting.get(conversation.id)?.waitingSince ?? "");
143
+ return Number.isFinite(time) ? time : Number.POSITIVE_INFINITY;
144
+ };
145
+ return (left, right) => {
146
+ const leftStart = start(left);
147
+ const rightStart = start(right);
148
+ return leftStart === rightStart ? 0 : leftStart < rightStart ? -1 : 1;
149
+ };
150
+ }
151
+
152
+ /**
153
+ * The time that sorts and groups a row: its last Message from a person or
154
+ * shown Junior reply. Tool, status, and quiet events do not move a row. A
155
+ * row with no Message yet uses its last activity.
156
+ */
136
157
  function activityTime(conversation: Conversation): number {
137
- const time = Date.parse(conversation.lastSeenAt);
158
+ const time = Date.parse(
159
+ conversation.lastMessageAt ?? conversation.lastSeenAt,
160
+ );
138
161
  return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
139
162
  }
140
163
 
@@ -0,0 +1,170 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useState,
5
+ type FocusEvent,
6
+ type PointerEvent,
7
+ } from "react";
8
+
9
+ import type { ConversationSection } from "./conversationSections";
10
+
11
+ /** The row order of a list at one moment: section keys and labels, row ids. */
12
+ export type HeldOrder = { ids: string[]; key: string; label: string }[];
13
+
14
+ /** Read the row order that a list shows now. */
15
+ export function sectionOrder(sections: ConversationSection[]): HeldOrder {
16
+ return sections.map((section) => ({
17
+ ids: section.conversations.map((conversation) => conversation.id),
18
+ key: section.key,
19
+ label: section.label,
20
+ }));
21
+ }
22
+
23
+ /**
24
+ * Show the latest conversations in a held row order. A row keeps its held
25
+ * section and place but shows its latest data. A row that is gone drops out.
26
+ * Rows that the latest order puts after every held row, such as the next
27
+ * page that the reader scrolls to, join at the end, so no held row moves.
28
+ * `pending` counts the rows that the latest order would add or move: a new
29
+ * row above a held row, a row in another section, or a row out of order in
30
+ * its section.
31
+ */
32
+ export function holdSectionOrder(
33
+ held: HeldOrder,
34
+ latest: ConversationSection[],
35
+ ): { pending: number; sections: ConversationSection[] } {
36
+ const latestRows = latest.flatMap((section) =>
37
+ section.conversations.map((conversation) => ({ conversation, section })),
38
+ );
39
+ const byId = new Map(
40
+ latestRows.map((row) => [row.conversation.id, row.conversation] as const),
41
+ );
42
+ const heldIds = new Set(held.flatMap((section) => section.ids));
43
+ let lastHeld = -1;
44
+ latestRows.forEach((row, index) => {
45
+ if (heldIds.has(row.conversation.id)) lastHeld = index;
46
+ });
47
+ const sections: ConversationSection[] = held.map((section) => ({
48
+ conversations: section.ids.flatMap((id) => byId.get(id) ?? []),
49
+ key: section.key,
50
+ label: section.label,
51
+ }));
52
+ for (const { conversation, section } of latestRows.slice(lastHeld + 1)) {
53
+ const target = sections.find((item) => item.key === section.key);
54
+ if (target) target.conversations.push(conversation);
55
+ else sections.push({ ...section, conversations: [conversation] });
56
+ }
57
+ const shown = new Map<string, { index: number; key: string }>();
58
+ for (const section of sections) {
59
+ section.conversations.forEach((conversation, index) =>
60
+ shown.set(conversation.id, { index, key: section.key }),
61
+ );
62
+ }
63
+ let pending = 0;
64
+ for (const section of latest) {
65
+ const kept = section.conversations.flatMap((conversation) => {
66
+ const place = shown.get(conversation.id);
67
+ return place?.key === section.key ? [place.index] : [];
68
+ });
69
+ pending += section.conversations.length - longestIncreasingRun(kept);
70
+ }
71
+ return {
72
+ pending,
73
+ sections: sections.filter((section) => section.conversations.length > 0),
74
+ };
75
+ }
76
+
77
+ /** Length of the longest increasing subsequence: the rows that need no move. */
78
+ function longestIncreasingRun(values: number[]): number {
79
+ const tails: number[] = [];
80
+ for (const value of values) {
81
+ let low = 0;
82
+ let high = tails.length;
83
+ while (low < high) {
84
+ const middle = (low + high) >> 1;
85
+ if (tails[middle]! < value) low = middle + 1;
86
+ else high = middle;
87
+ }
88
+ tails[low] = value;
89
+ }
90
+ return tails.length;
91
+ }
92
+
93
+ type Hold = { following: boolean; order: HeldOrder };
94
+
95
+ /**
96
+ * Hold a list's row order while the pointer is over it or focus is in it, so
97
+ * a row does not move under the reader's pointer. The hold starts only once
98
+ * the list is `settled`, so a pointer that rests on the list during a page
99
+ * load does not hold back the first rows. New activity waits as
100
+ * `pending` until the reader calls `applyPending`, or the pointer and focus
101
+ * leave. React events bubble through portals, so a row menu counts as in
102
+ * the list. `releaseHold` follows the latest order until the hold ends; row
103
+ * actions such as pin, archive, and mark unread call it, so their result
104
+ * shows at once.
105
+ */
106
+ export function useHeldListOrder(
107
+ sections: ConversationSection[],
108
+ options: {
109
+ /** False until the reads that place rows finish, so they can settle. */
110
+ settled: boolean;
111
+ },
112
+ ) {
113
+ const [hold, setHold] = useState<Hold | null>(null);
114
+ const [pointerInside, setPointerInside] = useState(false);
115
+ const [focusInside, setFocusInside] = useState(false);
116
+ const holding = options.settled && (pointerInside || focusInside);
117
+ // Start and end the hold during render, from the inputs that set it, so the
118
+ // first held render already shows the held order.
119
+ if (holding && !hold) {
120
+ setHold({ following: false, order: sectionOrder(sections) });
121
+ }
122
+ if (!holding && hold) setHold(null);
123
+
124
+ const view =
125
+ hold && !hold.following
126
+ ? holdSectionOrder(hold.order, sections)
127
+ : { pending: 0, sections };
128
+
129
+ // A focused pill that unmounts sends no blur, and focus falls to the body.
130
+ useEffect(() => {
131
+ if (focusInside && document.activeElement === document.body) {
132
+ setFocusInside(false);
133
+ }
134
+ }, [focusInside, view.pending]);
135
+
136
+ const applyPending = useCallback(
137
+ () =>
138
+ setHold((current) =>
139
+ current ? { following: false, order: sectionOrder(sections) } : current,
140
+ ),
141
+ [sections],
142
+ );
143
+ const releaseHold = useCallback(
144
+ () =>
145
+ setHold((current) =>
146
+ current ? { following: true, order: current.order } : current,
147
+ ),
148
+ [],
149
+ );
150
+ const holdProps = {
151
+ onBlur: (event: FocusEvent<HTMLElement>) => {
152
+ if (!event.currentTarget.contains(event.relatedTarget)) {
153
+ setFocusInside(false);
154
+ }
155
+ },
156
+ onFocus: () => setFocusInside(true),
157
+ // Touch has no hover, so only a mouse or pen holds the order.
158
+ onPointerEnter: (event: PointerEvent<HTMLElement>) => {
159
+ if (event.pointerType !== "touch") setPointerInside(true);
160
+ },
161
+ onPointerLeave: () => setPointerInside(false),
162
+ };
163
+ return {
164
+ applyPending,
165
+ holdProps,
166
+ pending: view.pending,
167
+ releaseHold,
168
+ sections: view.sections,
169
+ };
170
+ }
@@ -1,3 +1,4 @@
1
+ import { skillLabel } from "../components/SkillLink";
1
2
  import { stringifyPartValue } from "../format";
2
3
 
3
4
  const MAX_VALUE_LENGTH = 48;
@@ -11,7 +12,8 @@ export function toolCallPreview(name: string, input: unknown): string | null {
11
12
  }
12
13
 
13
14
  if (name === "loadSkill") {
14
- return fieldValue(input, "skill_name");
15
+ const name = fieldValue(input, "skill_name");
16
+ return name ? skillLabel(name) : null;
15
17
  }
16
18
 
17
19
  if (name === "webSearch") {
@@ -3,8 +3,13 @@ import type { ConversationTranscript, TranscriptViewMessage } from "../types";
3
3
  /** A provider whose mark the dashboard can show on a transcript message. */
4
4
  export type SourceProvider = "github" | "slack";
5
5
 
6
- /** Plugins without a mark stay unmarked. */
7
- function pluginSource(plugin: string | undefined): SourceProvider | undefined {
6
+ /**
7
+ * Return the provider whose mark a plugin shows. Plugins without a mark stay
8
+ * unmarked.
9
+ */
10
+ export function pluginSource(
11
+ plugin: string | undefined,
12
+ ): SourceProvider | undefined {
8
13
  return plugin === "github" ? "github" : undefined;
9
14
  }
10
15
 
@@ -16,8 +16,8 @@ type DetailCache = ConversationDetailReport & { detailEtag?: string };
16
16
 
17
17
  /**
18
18
  * Mark one conversation read or unread for the viewer. The feed and detail
19
- * caches change at once, so the row moves to or from Needs attention before
20
- * the request returns. A failed request puts the old caches back.
19
+ * caches change at once, so the row's unread marker changes before the
20
+ * request returns. A failed request puts the old caches back.
21
21
  */
22
22
  export function useMarkConversationUnread(
23
23
  conversationId: string,
@@ -879,6 +879,7 @@ export function buildConversations(
879
879
  return summaries
880
880
  .map((summary) => ({
881
881
  activityPreview: summary.activityPreview,
882
+ brief: summary.brief,
882
883
  annotations: summary.annotations,
883
884
  sidebarAnnotations: summary.sidebarAnnotations,
884
885
  archivedAt: summary.archivedAt,
@@ -895,6 +896,7 @@ export function buildConversations(
895
896
  displayTitle: summary.displayTitle,
896
897
  id: summary.conversationId,
897
898
  isParticipant: summary.isParticipant,
899
+ lastMessageAt: summary.lastMessageAt,
898
900
  lastProgressAt: summary.lastProgressAt,
899
901
  lastSeenAt: summary.lastSeenAt,
900
902
  locationId: summary.locationId,
@@ -910,7 +912,6 @@ export function buildConversations(
910
912
  kind: summary.kind,
911
913
  parentConversationId: summary.parentConversationId,
912
914
  threads: summary.threads && buildConversations(summary.threads),
913
- unfinishedWork: summary.unfinishedWork,
914
915
  awaitingAuthorization: summary.awaitingAuthorization,
915
916
  visibility: summary.visibility,
916
917
  }))
@@ -922,7 +923,22 @@ export function conversationFromDetail(
922
923
  detail: ConversationTranscript | undefined,
923
924
  ): Conversation | undefined {
924
925
  if (!detail) return undefined;
925
- const conversation = buildConversations([detail])[0];
926
+ // The detail carries the whole Brief; a row keeps the feed's short form.
927
+ const { brief, ...summary } = detail;
928
+ const conversation = buildConversations([
929
+ {
930
+ ...summary,
931
+ ...(brief
932
+ ? {
933
+ brief: {
934
+ intent: brief.content.intent,
935
+ status: brief.content.outcome.status,
936
+ summary: brief.content.summary,
937
+ },
938
+ }
939
+ : undefined),
940
+ },
941
+ ])[0];
926
942
  return conversation
927
943
  ? { ...conversation, displayTitle: detail.displayTitle }
928
944
  : undefined;
@@ -3,6 +3,7 @@ import type {
3
3
  AutomationSummary,
4
4
  AutomationTask,
5
5
  } from "@sentry/junior/api/schema";
6
+ import { skillLabel } from "../../components/SkillLink";
6
7
  import { Button } from "../../components/ui/button";
7
8
  import { Card } from "../../components/layout/Card";
8
9
  import { cn } from "../../styles";
@@ -118,7 +119,7 @@ function taskChangeLabel(saved: AutomationTask, next: AutomationTask): string {
118
119
 
119
120
  function taskLabel(task: AutomationTask): string {
120
121
  if (task.kind === "instruction") return task.instruction || "Empty";
121
- const skill = task.skill || "No skill chosen";
122
+ const skill = task.skill ? skillLabel(task.skill) : "No skill chosen";
122
123
  return task.context ? `${skill} · ${task.context}` : skill;
123
124
  }
124
125
 
@@ -18,6 +18,7 @@ import {
18
18
  import { ActorAvatar } from "../../components/ActorAvatar";
19
19
  import { Fact, FactList, FactSection } from "../../components/FactSection";
20
20
  import { InlineError } from "../../components/InlineError";
21
+ import { SkillLink } from "../../components/SkillLink";
21
22
  import { StatusDot } from "../../components/StatusDot";
22
23
  import { Card } from "../../components/layout/Card";
23
24
  import { Button } from "../../components/ui/button";
@@ -290,9 +291,8 @@ function AutomationDefinition(props: { automation: AutomationSummary }) {
290
291
  <FactSection title="Definition">
291
292
  {task.kind === "skill" ? (
292
293
  <p className="m-0 text-sm text-dashboard-text-muted">
293
- Runs the{" "}
294
- <span className="font-mono text-dashboard-text">{task.skill}</span>{" "}
295
- skill{task.context ? " with this context:" : "."}
294
+ Runs <SkillLink name={task.skill} />
295
+ {task.context ? " with this context:" : "."}
296
296
  </p>
297
297
  ) : null}
298
298
  {written ? (
@@ -14,7 +14,10 @@ import {
14
14
  PopoverContent,
15
15
  PopoverTrigger,
16
16
  } from "../../components/ui/popover";
17
+ import { MatchedLabel } from "../../components/MatchedLabel";
18
+ import { skillLabel } from "../../components/SkillLink";
17
19
  import { cn } from "../../styles";
20
+ import { searchSkills, type SkillSearchRow } from "./skillSearch";
18
21
 
19
22
  /**
20
23
  * Choose the skill an Automation runs. A saved skill that is no longer
@@ -29,6 +32,7 @@ export function AutomationSkillPicker(props: {
29
32
  onChange(skill: string): void;
30
33
  }) {
31
34
  const [open, setOpen] = useState(false);
35
+ const [query, setQuery] = useState("");
32
36
  const selected = props.skills.find((skill) => skill.name === props.value);
33
37
  const missing = Boolean(props.value) && !selected;
34
38
  const groups = [
@@ -41,8 +45,20 @@ export function AutomationSkillPicker(props: {
41
45
  skills: props.skills.filter((skill) => skill.pluginProvider),
42
46
  },
43
47
  ].filter((group) => group.skills.length > 0);
48
+ // Names show as `$skill-name`, so a pasted label still searches by name.
49
+ const search = query.trim().replace(/^\$/, "");
50
+ const choose = (skill: string) => {
51
+ props.onChange(skill);
52
+ setOpen(false);
53
+ };
44
54
  return (
45
- <Popover open={open} onOpenChange={setOpen}>
55
+ <Popover
56
+ open={open}
57
+ onOpenChange={(next) => {
58
+ setOpen(next);
59
+ if (!next) setQuery("");
60
+ }}
61
+ >
46
62
  <PopoverTrigger
47
63
  id={props.id}
48
64
  aria-invalid={props.invalid || missing || undefined}
@@ -61,7 +77,7 @@ export function AutomationSkillPicker(props: {
61
77
  "text-dashboard-text-muted line-through decoration-dashboard-text-muted/60",
62
78
  )}
63
79
  >
64
- {props.value}
80
+ {skillLabel(props.value)}
65
81
  </span>
66
82
  {missing ? (
67
83
  <SkillBadge tone="danger">Not found</SkillBadge>
@@ -90,38 +106,34 @@ export function AutomationSkillPicker(props: {
90
106
  align="start"
91
107
  className="w-(--anchor-width) max-w-[calc(100vw-2rem)] min-w-80 overflow-hidden p-0"
92
108
  >
93
- <Command label="Skills">
94
- <CommandInput placeholder="Search by name or description" />
109
+ {/* Ranked here so a name match beats a description match. */}
110
+ <Command label="Skills" shouldFilter={false}>
111
+ <CommandInput
112
+ placeholder="Search by name or description"
113
+ value={query}
114
+ onValueChange={setQuery}
115
+ />
95
116
  <CommandList>
96
117
  <CommandEmpty>No skills match.</CommandEmpty>
97
- {groups.map((group) => (
98
- <CommandGroup heading={group.heading} key={group.heading}>
99
- {group.skills.map((skill) => (
100
- <CommandItem
101
- key={skill.name}
102
- value={skill.name}
103
- keywords={[skill.description]}
104
- onSelect={() => {
105
- props.onChange(skill.name);
106
- setOpen(false);
107
- }}
108
- className="items-start"
109
- >
110
- <span className="grid min-w-0 flex-1 gap-0.5">
111
- <span className="flex min-w-0 items-center gap-2">
112
- <span className="truncate font-mono">{skill.name}</span>
113
- {skill.disableModelInvocation ? (
114
- <SkillBadge>Manual only</SkillBadge>
115
- ) : null}
116
- </span>
117
- <span className="truncate text-xs text-dashboard-text-muted">
118
- {skill.description}
119
- </span>
120
- </span>
121
- </CommandItem>
118
+ {search
119
+ ? searchSkills(props.skills, search).map((row) => (
120
+ <SkillOption
121
+ key={row.skill.name}
122
+ row={row}
123
+ onSelect={choose}
124
+ />
125
+ ))
126
+ : groups.map((group) => (
127
+ <CommandGroup heading={group.heading} key={group.heading}>
128
+ {group.skills.map((skill) => (
129
+ <SkillOption
130
+ key={skill.name}
131
+ row={{ skill }}
132
+ onSelect={choose}
133
+ />
134
+ ))}
135
+ </CommandGroup>
122
136
  ))}
123
- </CommandGroup>
124
- ))}
125
137
  </CommandList>
126
138
  </Command>
127
139
  </PopoverContent>
@@ -129,6 +141,38 @@ export function AutomationSkillPicker(props: {
129
141
  );
130
142
  }
131
143
 
144
+ function SkillOption(props: {
145
+ row: SkillSearchRow;
146
+ onSelect(skill: string): void;
147
+ }) {
148
+ const { skill } = props.row;
149
+ return (
150
+ <CommandItem
151
+ value={skill.name}
152
+ onSelect={() => props.onSelect(skill.name)}
153
+ className="items-start"
154
+ >
155
+ <span className="grid min-w-0 flex-1 gap-0.5">
156
+ <span className="flex min-w-0 items-center gap-2">
157
+ <span className="truncate font-mono">
158
+ $
159
+ <MatchedLabel label={skill.name} matches={props.row.nameMatches} />
160
+ </span>
161
+ {skill.disableModelInvocation ? (
162
+ <SkillBadge>Manual only</SkillBadge>
163
+ ) : null}
164
+ </span>
165
+ <span className="truncate text-xs text-dashboard-text-muted">
166
+ <MatchedLabel
167
+ label={skill.description}
168
+ matches={props.row.descriptionMatches}
169
+ />
170
+ </span>
171
+ </span>
172
+ </CommandItem>
173
+ );
174
+ }
175
+
132
176
  function SkillTile(props: { plugin: boolean }) {
133
177
  return (
134
178
  <span className="grid size-8 shrink-0 place-items-center rounded-md border border-dashboard-accent/15 bg-dashboard-accent/[0.075] text-dashboard-accent-text">