@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.
- package/dist/app.js +7 -5
- package/dist/client-manifest.json +1 -1
- package/dist/index.js +7 -5
- package/dist/public/chunks/{AdminPage-mn71yp2v.js → AdminPage-lctmhvpg.js} +1 -1
- package/dist/public/chunks/{AnchorHeading-cp3vx8qa.js → AnchorHeading-ixbupfij.js} +1 -1
- package/dist/public/chunks/{Area-h08sxa33.js → Area-d95rfkv5.js} +1 -1
- package/dist/public/chunks/AutomationPage-m78li1hv.js +3 -0
- package/dist/public/chunks/{AutomationRunDetails-jjdhbqtn.js → AutomationRunDetails-dx5lutte.js} +1 -1
- package/dist/public/chunks/{AutomationRunsPage-j50ps7nt.js → AutomationRunsPage-cba6nbxd.js} +1 -1
- package/dist/public/chunks/{AutomationStatus-czfg6ayl.js → AutomationStatus-eqq5lrad.js} +1 -1
- package/dist/public/chunks/AutomationsPage-cp7wtt2v.js +8 -0
- package/dist/public/chunks/{BarChart-e3oi6in1.js → BarChart-lbkezxbi.js} +1 -1
- package/dist/public/chunks/{BoardPage-f0uxqg7u.js → BoardPage-m4zqf62q.js} +1 -1
- package/dist/public/chunks/{CodeChangeRow-dk3gkjut.js → CodeChangeRow-d620nk75.js} +2 -2
- package/dist/public/chunks/CodePage-h2ayars2.js +1 -0
- package/dist/public/chunks/{CodeSummaryCards-o1po2jis.js → CodeSummaryCards-iotfblbp.js} +1 -1
- package/dist/public/chunks/{ConnectionsPage-mholegdk.js → ConnectionsPage-nm7w1i05.js} +2 -2
- package/dist/public/chunks/{DirectoryMetric-dgzufji4.js → DirectoryMetric-huy2ptzz.js} +1 -1
- package/dist/public/chunks/{DrawerContent-ovjgr16m.js → DrawerContent-n3biwp5m.js} +1 -1
- package/dist/public/chunks/{FilterBar-mtnh7fbi.js → FilterBar-dqnt967g.js} +1 -1
- package/dist/public/chunks/FormNotice-kj5pzsl7.js +1 -0
- package/dist/public/chunks/{LocationDetailPage-ggd6zgzp.js → LocationDetailPage-iqtx76w6.js} +2 -2
- package/dist/public/chunks/{LocationsPage-fh6vzxa1.js → LocationsPage-ju5jfejt.js} +2 -2
- package/dist/public/chunks/{McpSettingsPage-fm3fh2ux.js → McpSettingsPage-brav5c42.js} +1 -1
- package/dist/public/chunks/{PeoplePage-odrbcmhj.js → PeoplePage-lfq8nrto.js} +2 -2
- package/dist/public/chunks/{PersonProfilePage-gynexe89.js → PersonProfilePage-ep45sxr6.js} +1 -1
- package/dist/public/chunks/{PersonalTokensPage-4o4zj02w.js → PersonalTokensPage-jbt4q7uz.js} +1 -1
- package/dist/public/chunks/PluginReportRecordSet-ml18z19r.js +1 -0
- package/dist/public/chunks/{PluginUserPage-kwylbbpr.js → PluginUserPage-ilar25ic.js} +2 -2
- package/dist/public/chunks/RepositoryPage-o9m3vty4.js +1 -0
- package/dist/public/chunks/SettingsPage-jc9et1qq.js +1 -0
- package/dist/public/chunks/SystemPage-bbp8cdds.js +15 -0
- package/dist/public/chunks/{WorkspaceFormPage-hwvyvnuk.js → WorkspaceFormPage-kshmsdek.js} +2 -2
- package/dist/public/chunks/{WorkspacesPage-lu9w3ugz.js → WorkspacesPage-ohasc10u.js} +1 -1
- package/dist/public/chunks/alert-ifqo1uj7.js +1 -0
- package/dist/public/chunks/{automationFormat-hd7pnocr.js → automationFormat-kv314fzh.js} +1 -1
- package/dist/public/chunks/{chart-myr1oeen.js → chart-il2ahs5y.js} +1 -1
- package/dist/public/chunks/{circle-dollar-sign-fpl4dmok.js → circle-dollar-sign-h7k4kr7s.js} +1 -1
- package/dist/public/chunks/{code-highlighter-i3ux67or.js → code-highlighter-ea4j0ys1.js} +1 -1
- package/dist/public/chunks/{coins-bqkaxeba.js → coins-l2c8c17w.js} +1 -1
- package/dist/public/chunks/{format-gle1s0ri.js → format-flkutk9e.js} +2 -2
- package/dist/public/chunks/junior-g62bxb2t.js +4 -0
- package/dist/public/chunks/{native-select-jfknh8t4.js → native-select-fxs3p0bj.js} +1 -1
- package/dist/public/chunks/{plus-gndielka.js → plus-54e3zzys.js} +1 -1
- package/dist/public/chunks/vendor-icons-blmhaswc.js +698 -0
- package/dist/public/chunks/{workspaceQueries-m1ksz95c.js → workspaceQueries-onxawhg4.js} +1 -1
- package/dist/public/client-ctcthgam.js +165 -0
- package/dist/public/styles-4CIT7L7K.css +2 -0
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/api.ts +16 -0
- package/src/client/components/MarkdownViewToggle.tsx +24 -0
- package/src/client/components/SkillLink.tsx +119 -0
- package/src/client/conversations/ConversationHomeList.tsx +87 -76
- package/src/client/conversations/ConversationListStatusIcon.tsx +39 -55
- package/src/client/conversations/ConversationSectionList.tsx +101 -0
- package/src/client/conversations/ConversationSidebar.tsx +159 -169
- package/src/client/conversations/ConversationWorkspace.tsx +5 -16
- package/src/client/conversations/TextAttachmentPreview.tsx +5 -14
- package/src/client/conversations/WaitingChip.tsx +78 -0
- package/src/client/conversations/conversationSections.ts +78 -55
- package/src/client/conversations/heldListOrder.ts +170 -0
- package/src/client/conversations/toolCallPreview.ts +3 -1
- package/src/client/conversations/transcriptSource.ts +7 -2
- package/src/client/conversations/useMarkConversationUnread.ts +2 -2
- package/src/client/format.ts +18 -2
- package/src/client/pages/automations/AutomationDraftChanges.tsx +2 -1
- package/src/client/pages/automations/AutomationOverview.tsx +3 -3
- package/src/client/pages/automations/AutomationSkillPicker.tsx +75 -31
- package/src/client/pages/automations/AutomationTaskFields.tsx +3 -13
- package/src/client/pages/automations/AutomationVersionsTab.tsx +2 -5
- package/src/client/pages/automations/skillSearch.ts +50 -0
- package/src/client/pages/code/RepositorySections.tsx +14 -5
- package/src/client/pages/code/RepositoryTabs.tsx +5 -11
- package/src/client/pages/system/PluginDetails.tsx +15 -8
- package/src/client/pages/system/SkillInventory.tsx +2 -6
- package/src/client/review-desk/queries.ts +30 -23
- package/src/client/review-desk/reviewItemFormat.ts +3 -1
- package/src/client/types.ts +5 -1
- package/src/mock-reporting/review-items.ts +6 -2
- package/dist/public/chunks/AutomationPage-e1l74ezg.js +0 -10
- package/dist/public/chunks/AutomationsPage-cphxl4l6.js +0 -8
- package/dist/public/chunks/CodePage-i97ivyji.js +0 -1
- package/dist/public/chunks/FormNotice-eyo2kdte.js +0 -1
- package/dist/public/chunks/PluginReportRecordSet-hm1cwp6c.js +0 -1
- package/dist/public/chunks/RepositoryPage-fti2vfu4.js +0 -1
- package/dist/public/chunks/SettingsPage-kxspx37h.js +0 -1
- package/dist/public/chunks/SystemPage-jurblfgy.js +0 -15
- package/dist/public/chunks/SystemPlugins-jr37f1cv.js +0 -1
- package/dist/public/chunks/junior-hdb4wc4q.js +0 -4
- package/dist/public/chunks/vendor-icons-mxz7yi04.js +0 -692
- package/dist/public/client-f7asutg5.js +0 -165
- 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
|
|
11
|
+
// Pinned sorts first, then Needs you, then every time section.
|
|
11
12
|
const PINNED_SECTION = { key: "pinned", label: "Pinned", order: -2 };
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
:
|
|
45
|
-
?
|
|
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
|
|
68
|
-
* a Turn waits on a person to finish an authorization
|
|
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
|
|
72
|
-
conversation: Conversation,
|
|
73
|
-
attentionIds: ReadonlySet<string>,
|
|
74
|
-
): boolean {
|
|
80
|
+
function waitsOnViewer(conversation: Conversation, waiting: Waiting): boolean {
|
|
75
81
|
return (
|
|
76
|
-
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
93
|
+
export function pinnedAndWaitingFirst(
|
|
102
94
|
conversations: Conversation[],
|
|
103
|
-
|
|
95
|
+
waiting: Waiting,
|
|
104
96
|
): Conversation[] {
|
|
105
97
|
const rank = (conversation: Conversation) =>
|
|
106
|
-
conversation.pinnedAt
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
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
|
|
20
|
-
*
|
|
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,
|
package/src/client/format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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">
|