@sentry/junior-dashboard 0.275.0 → 0.277.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 +194 -40
- package/dist/client.js +120 -92
- package/dist/index.d.ts +1 -1
- package/dist/index.js +189 -38
- package/dist/tailwind.css +1 -1
- package/package.json +5 -4
- package/src/api/schema.ts +20 -2
- package/src/app.ts +88 -15
- package/src/auth.ts +5 -1
- package/src/client/components/FormNotice.tsx +11 -14
- package/src/client/components/ProfileMenu.tsx +5 -6
- package/src/client/conversations/AutoArchiveToggle.tsx +35 -0
- package/src/client/conversations/ConversationArchiveNotices.tsx +1 -1
- package/src/client/conversations/ConversationChildAgents.tsx +2 -1
- package/src/client/conversations/ConversationComposer.tsx +42 -26
- package/src/client/conversations/ConversationDetails.tsx +66 -35
- package/src/client/conversations/ConversationHeader.tsx +19 -2
- package/src/client/conversations/ConversationHeaderActions.tsx +23 -0
- package/src/client/conversations/ConversationHomeList.tsx +1 -1
- package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
- package/src/client/conversations/ConversationMemories.tsx +114 -77
- package/src/client/conversations/ConversationMeta.tsx +47 -42
- package/src/client/conversations/ConversationPage.tsx +183 -60
- package/src/client/conversations/ConversationSidebar.tsx +80 -24
- package/src/client/conversations/ConversationTranscript.tsx +37 -8
- package/src/client/conversations/ConversationWatches.tsx +42 -26
- package/src/client/conversations/ConversationWorkspace.tsx +4 -4
- package/src/client/conversations/DetailsList.tsx +8 -14
- package/src/client/conversations/MemoryIcons.tsx +23 -0
- package/src/client/conversations/PendingMailboxStack.tsx +11 -1
- package/src/client/conversations/QuickQuestion.tsx +476 -0
- package/src/client/conversations/TelemetryMetrics.tsx +2 -1
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptEventView.tsx +25 -2
- package/src/client/conversations/TranscriptMemoryPopover.tsx +174 -0
- package/src/client/conversations/TranscriptMessageView.tsx +46 -16
- package/src/client/conversations/TranscriptRows.tsx +9 -2
- package/src/client/conversations/TranscriptTurnContextView.tsx +9 -5
- package/src/client/conversations/UsageCard.tsx +3 -3
- package/src/client/conversations/autoArchiveQueries.ts +53 -0
- package/src/client/conversations/conversationArchive.ts +255 -0
- package/src/client/conversations/conversationMemory.ts +139 -0
- package/src/client/conversations/conversationSections.ts +24 -18
- package/src/client/conversations/eventTranscript.ts +8 -0
- package/src/client/conversations/queries.ts +48 -238
- package/src/client/conversations/usePinConversation.ts +88 -0
- package/src/client/display-text/DisplayText.tsx +7 -2
- package/src/client/display-text/codeBlocks.ts +92 -0
- package/src/client/format.ts +3 -67
- package/src/client/pages/code/RepositoryTabs.tsx +2 -2
- package/src/client/pages/settings/AutoArchiveSettings.tsx +145 -0
- package/src/client/pages/settings/SettingsPage.tsx +2 -0
- package/src/client/review-desk/ConversationReviewItems.tsx +7 -9
- package/src/client/review-desk/ReviewItemActions.tsx +1 -1
- package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
- package/src/client/review-desk/ReviewItemContext.tsx +41 -23
- package/src/client/review-desk/queries.ts +2 -1
- package/src/client/review-desk/reviewItemFormat.ts +16 -0
- package/src/client/sentry.ts +77 -0
- package/src/client/types.ts +7 -0
- package/src/client.tsx +3 -0
- package/src/conversation-cache.ts +1 -1
- package/src/mock-reporting/fixtures.ts +103 -27
- package/src/mock-reporting/routes.ts +47 -0
- package/src/mock-reporting/watches.ts +2 -0
- package/src/shell.ts +27 -2
- package/src/tailwind.css +8 -19
- package/src/client/components/ActiveIndicator.tsx +0 -14
|
@@ -2,7 +2,8 @@ import { memo } from "react";
|
|
|
2
2
|
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
3
3
|
|
|
4
4
|
import { HighlightedCode } from "../code";
|
|
5
|
-
import {
|
|
5
|
+
import { CopyButton } from "../components/CopyButton";
|
|
6
|
+
import { parseMarkdownBlocks } from "./codeBlocks";
|
|
6
7
|
import { MarkdownText } from "./markdown";
|
|
7
8
|
import { MentionPeopleProvider } from "./mentions";
|
|
8
9
|
|
|
@@ -56,10 +57,14 @@ function DisplayTextBlocks(props: { chat?: boolean; text: string }) {
|
|
|
56
57
|
|
|
57
58
|
return (
|
|
58
59
|
<div
|
|
59
|
-
className="min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel
|
|
60
|
+
className="group/code relative min-w-0 overflow-hidden rounded-md border border-dashboard-border-subtle bg-dashboard-surface-panel py-2.5 pl-3 pr-10"
|
|
60
61
|
key={index}
|
|
61
62
|
>
|
|
62
63
|
<HighlightedCode code={block.code} language={block.language} />
|
|
64
|
+
{/* Touch screens cannot hover, so keep the button visible there. */}
|
|
65
|
+
<div className="absolute right-0.5 top-0.5 transition-opacity [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/code:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/code:opacity-100">
|
|
66
|
+
<CopyButton getText={() => block.code} label="Copy code" />
|
|
67
|
+
</div>
|
|
63
68
|
</div>
|
|
64
69
|
);
|
|
65
70
|
})}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { BundledLanguage } from "shiki/bundle/web";
|
|
2
|
+
|
|
3
|
+
import { dashboardCodeLanguages } from "../code-languages";
|
|
4
|
+
import { detectOutputLanguage, prettyJsonData } from "../format";
|
|
5
|
+
import type { CodeBlock } from "../types";
|
|
6
|
+
|
|
7
|
+
// Shiki has no language detection, and agents often leave fences untagged.
|
|
8
|
+
// Only clear signals select a language, so prose in a fence stays plain.
|
|
9
|
+
const SCRIPT_PATTERN =
|
|
10
|
+
/^javascript:|=>|\b(?:const|let|var)\s+[\w$]+\s*=|\bfunction\b\s*[\w$]*\s*\(|^\s*(?:import\s.+\sfrom\s|export\s+(?:(?:async|class|const|default|enum|function|interface|let|type|var)\s|[{*]))/m;
|
|
11
|
+
const PYTHON_PATTERN =
|
|
12
|
+
/^\s*(?:(?:async\s+)?def\s+\w+\s*\(.*\)\s*(?:->.*)?:|class\s+\w+(?:\([^)]*\))?\s*:|from\s+[\w.]+\s+import\s|import\s+[\w.]+\s*$)/m;
|
|
13
|
+
const SHELL_PATTERN =
|
|
14
|
+
/^\s*(?:\$\s|(?:bun|brew|cargo|cat|cd|cp|curl|docker|echo|export|gh|git|go|grep|kubectl|ls|make|mkdir|mv|npm|npx|pip|pnpm|rm|ssh|sudo|uv|wget|yarn)\s)/;
|
|
15
|
+
|
|
16
|
+
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
|
17
|
+
console: "shellscript",
|
|
18
|
+
htm: "html",
|
|
19
|
+
js: "javascript",
|
|
20
|
+
jsonl: "json",
|
|
21
|
+
md: "markdown",
|
|
22
|
+
ndjson: "json",
|
|
23
|
+
sh: "shellscript",
|
|
24
|
+
text: "markdown",
|
|
25
|
+
txt: "markdown",
|
|
26
|
+
xml: "xml",
|
|
27
|
+
yml: "yaml",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Parse transcript prose as Markdown or JSON while preserving fenced code. */
|
|
31
|
+
export function parseMarkdownBlocks(text: string): CodeBlock[] {
|
|
32
|
+
const blocks: CodeBlock[] = [];
|
|
33
|
+
// The info string can hold more than the language, such as `c++` or
|
|
34
|
+
// `js title="x"`. Only its first word names the language.
|
|
35
|
+
const fence = /^([ \t]*)```([^\n`]*)\n([\s\S]*?)```/gm;
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
let match: RegExpExecArray | null;
|
|
38
|
+
while ((match = fence.exec(text))) {
|
|
39
|
+
const prose = text.slice(cursor, match.index).trim();
|
|
40
|
+
if (prose) blocks.push(proseBlock(prose));
|
|
41
|
+
const tag = match[2]?.trim().split(/\s+/)[0];
|
|
42
|
+
const code = stripFenceIndent(match[3] ?? "", match[1]?.length ?? 0);
|
|
43
|
+
const language = tag ? normalizeLanguage(tag) : guessLanguage(code);
|
|
44
|
+
blocks.push({
|
|
45
|
+
code: formatCodeBlock(code, language),
|
|
46
|
+
fenced: true,
|
|
47
|
+
language,
|
|
48
|
+
});
|
|
49
|
+
cursor = match.index + match[0].length;
|
|
50
|
+
}
|
|
51
|
+
const rest = text.slice(cursor).trim();
|
|
52
|
+
if (rest) blocks.push(proseBlock(rest));
|
|
53
|
+
return blocks.length > 0 ? blocks : [proseBlock(text)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function proseBlock(text: string): CodeBlock {
|
|
57
|
+
const language = detectOutputLanguage(text);
|
|
58
|
+
return { code: formatCodeBlock(text, language), fenced: false, language };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeLanguage(tag: string): BundledLanguage {
|
|
62
|
+
const normalized = tag.toLowerCase();
|
|
63
|
+
const candidate = LANGUAGE_ALIASES[normalized] ?? normalized;
|
|
64
|
+
return Object.hasOwn(dashboardCodeLanguages, candidate)
|
|
65
|
+
? (candidate as BundledLanguage)
|
|
66
|
+
: "markdown";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function guessLanguage(code: string): BundledLanguage {
|
|
70
|
+
if (detectOutputLanguage(code) === "json") return "json";
|
|
71
|
+
if (SCRIPT_PATTERN.test(code)) return "tsx";
|
|
72
|
+
if (PYTHON_PATTERN.test(code)) return "python";
|
|
73
|
+
if (SHELL_PATTERN.test(code)) return "shellscript";
|
|
74
|
+
return "markdown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A fence in a list item is indented, and so are its code lines. Remove that
|
|
78
|
+
// indent, as CommonMark does.
|
|
79
|
+
function stripFenceIndent(code: string, indent: number): string {
|
|
80
|
+
if (indent === 0) return code;
|
|
81
|
+
return code
|
|
82
|
+
.split("\n")
|
|
83
|
+
.map((line) => {
|
|
84
|
+
const lead = line.length - line.trimStart().length;
|
|
85
|
+
return line.slice(Math.min(indent, lead));
|
|
86
|
+
})
|
|
87
|
+
.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatCodeBlock(code: string, language: BundledLanguage): string {
|
|
91
|
+
return language === "json" ? (prettyJsonData(code) ?? code) : code;
|
|
92
|
+
}
|
package/src/client/format.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { BundledLanguage } from "shiki/bundle/web";
|
|
2
|
-
import { dashboardCodeLanguages } from "./code-languages";
|
|
3
2
|
import type {
|
|
4
3
|
ActorIdentity,
|
|
5
4
|
ConversationAuxiliaryCosts,
|
|
@@ -8,7 +7,6 @@ import type {
|
|
|
8
7
|
ConversationUsage,
|
|
9
8
|
} from "@sentry/junior/api/schema";
|
|
10
9
|
import type {
|
|
11
|
-
CodeBlock,
|
|
12
10
|
Conversation,
|
|
13
11
|
ConversationTranscript,
|
|
14
12
|
TranscriptViewMessage,
|
|
@@ -815,28 +813,6 @@ export function automationPath(
|
|
|
815
813
|
return tab === "overview" ? base : `${base}/${tab}`;
|
|
816
814
|
}
|
|
817
815
|
|
|
818
|
-
function normalizeLanguage(language: string | undefined): BundledLanguage {
|
|
819
|
-
const normalized = language?.trim().toLowerCase();
|
|
820
|
-
if (!normalized) return "markdown";
|
|
821
|
-
const aliases: Record<string, BundledLanguage> = {
|
|
822
|
-
console: "shellscript",
|
|
823
|
-
htm: "html",
|
|
824
|
-
js: "javascript",
|
|
825
|
-
jsonl: "json",
|
|
826
|
-
md: "markdown",
|
|
827
|
-
ndjson: "json",
|
|
828
|
-
sh: "shellscript",
|
|
829
|
-
text: "markdown",
|
|
830
|
-
txt: "markdown",
|
|
831
|
-
xml: "xml",
|
|
832
|
-
yml: "yaml",
|
|
833
|
-
};
|
|
834
|
-
const candidate = aliases[normalized] ?? normalized;
|
|
835
|
-
return Object.hasOwn(dashboardCodeLanguages, candidate)
|
|
836
|
-
? (candidate as BundledLanguage)
|
|
837
|
-
: "markdown";
|
|
838
|
-
}
|
|
839
|
-
|
|
840
816
|
function prettyJson(text: string): string | undefined {
|
|
841
817
|
const trimmed = text.trim();
|
|
842
818
|
if (!trimmed) return undefined;
|
|
@@ -863,14 +839,11 @@ function prettyJsonl(text: string): string | undefined {
|
|
|
863
839
|
return formatted.join("\n");
|
|
864
840
|
}
|
|
865
841
|
|
|
866
|
-
|
|
842
|
+
/** Pretty-print JSON or JSONL text, or return undefined for other text. */
|
|
843
|
+
export function prettyJsonData(text: string): string | undefined {
|
|
867
844
|
return prettyJson(text) ?? prettyJsonl(text);
|
|
868
845
|
}
|
|
869
846
|
|
|
870
|
-
function formatCodeBlock(code: string, language: BundledLanguage): string {
|
|
871
|
-
return language === "json" ? (prettyJsonData(code) ?? code) : code;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
847
|
/**
|
|
875
848
|
* Detect the language for LLM text output prose: json if the text is valid
|
|
876
849
|
* JSON or JSONL, markdown otherwise. Never auto-detects XML, HTML, TypeScript,
|
|
@@ -889,44 +862,6 @@ export function detectOutputLanguage(text: string): BundledLanguage {
|
|
|
889
862
|
return "markdown";
|
|
890
863
|
}
|
|
891
864
|
|
|
892
|
-
/** Parse transcript prose as Markdown or JSON while preserving fenced code. */
|
|
893
|
-
export function parseMarkdownBlocks(text: string): CodeBlock[] {
|
|
894
|
-
const blocks: CodeBlock[] = [];
|
|
895
|
-
const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g;
|
|
896
|
-
let cursor = 0;
|
|
897
|
-
let match: RegExpExecArray | null;
|
|
898
|
-
while ((match = fence.exec(text))) {
|
|
899
|
-
const prose = text.slice(cursor, match.index).trim();
|
|
900
|
-
if (prose) {
|
|
901
|
-
const language = detectOutputLanguage(prose);
|
|
902
|
-
blocks.push({
|
|
903
|
-
code: formatCodeBlock(prose, language),
|
|
904
|
-
fenced: false,
|
|
905
|
-
language,
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
const language = normalizeLanguage(match[1]);
|
|
909
|
-
blocks.push({
|
|
910
|
-
code: formatCodeBlock(match[2] ?? "", language),
|
|
911
|
-
fenced: true,
|
|
912
|
-
language,
|
|
913
|
-
});
|
|
914
|
-
cursor = match.index + match[0].length;
|
|
915
|
-
}
|
|
916
|
-
const rest = text.slice(cursor).trim();
|
|
917
|
-
if (rest) {
|
|
918
|
-
const language = detectOutputLanguage(rest);
|
|
919
|
-
blocks.push({
|
|
920
|
-
code: formatCodeBlock(rest, language),
|
|
921
|
-
fenced: false,
|
|
922
|
-
language,
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
if (blocks.length > 0) return blocks;
|
|
926
|
-
const language = detectOutputLanguage(text);
|
|
927
|
-
return [{ code: formatCodeBlock(text, language), fenced: false, language }];
|
|
928
|
-
}
|
|
929
|
-
|
|
930
865
|
/** Convert SQL conversation summaries into dashboard rows. */
|
|
931
866
|
export function buildConversations(
|
|
932
867
|
summaries: ConversationSummaryReport[],
|
|
@@ -937,6 +872,7 @@ export function buildConversations(
|
|
|
937
872
|
annotations: summary.annotations,
|
|
938
873
|
sidebarAnnotations: summary.sidebarAnnotations,
|
|
939
874
|
archivedAt: summary.archivedAt,
|
|
875
|
+
pinnedAt: summary.pinnedAt,
|
|
940
876
|
auxiliaryCosts: summary.auxiliaryCosts,
|
|
941
877
|
channel: summary.channel,
|
|
942
878
|
channelName: summary.channelName,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../../components/controls/TimeRangeSelector";
|
|
17
17
|
import { Card } from "../../components/layout/Card";
|
|
18
18
|
import { ConversationHomeList } from "../../conversations/ConversationHomeList";
|
|
19
|
-
import {
|
|
19
|
+
import { pinnedAndAttentionFirst } from "../../conversations/conversationSections";
|
|
20
20
|
import { buildConversations, getDashboardTimeZone } from "../../format";
|
|
21
21
|
import { useDebouncedValue } from "../../debouncedValue";
|
|
22
22
|
import { useWaitingConversationIds } from "../../review-desk/queries";
|
|
@@ -54,7 +54,7 @@ export function RepositoryConversationsTab(props: { repositoryId: string }) {
|
|
|
54
54
|
) : (
|
|
55
55
|
<ConversationHomeList
|
|
56
56
|
conversations={pageItems(
|
|
57
|
-
|
|
57
|
+
pinnedAndAttentionFirst(conversations, waitingConversationIds),
|
|
58
58
|
page,
|
|
59
59
|
PAGE_SIZE,
|
|
60
60
|
)}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AUTO_ARCHIVE_MAX_GRACE_HOURS,
|
|
5
|
+
dashboardIdentitySchema,
|
|
6
|
+
type DashboardIdentity,
|
|
7
|
+
} from "../../../api/schema";
|
|
8
|
+
import { Button } from "../../components/ui/button";
|
|
9
|
+
import { Checkbox } from "../../components/ui/checkbox";
|
|
10
|
+
import { Field, FieldDescription, FieldLabel } from "../../components/ui/field";
|
|
11
|
+
import { NativeSelect } from "../../components/ui/native-select";
|
|
12
|
+
import { InlineError } from "../../components/InlineError";
|
|
13
|
+
import { Card } from "../../components/layout/Card";
|
|
14
|
+
import { useArchiveDoneConversations } from "../../conversations/autoArchiveQueries";
|
|
15
|
+
import { patch } from "../../http";
|
|
16
|
+
import type { DashboardCoreData } from "../../types";
|
|
17
|
+
|
|
18
|
+
const dashboardCoreQueryKey = ["dashboard", "core"] as const;
|
|
19
|
+
|
|
20
|
+
/** Grace periods offered in Settings, in hours. */
|
|
21
|
+
const AUTO_ARCHIVE_GRACE_OPTIONS = [1, 6, 24, 72, AUTO_ARCHIVE_MAX_GRACE_HOURS];
|
|
22
|
+
|
|
23
|
+
function formatArchivedCount(count: number): string {
|
|
24
|
+
if (count === 0) return "No done conversations to archive.";
|
|
25
|
+
return count === 1
|
|
26
|
+
? "Archived 1 conversation."
|
|
27
|
+
: `Archived ${count} conversations.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatGraceHours(hours: number): string {
|
|
31
|
+
if (hours % 24 !== 0) return hours === 1 ? "1 hour" : `${hours} hours`;
|
|
32
|
+
const days = hours / 24;
|
|
33
|
+
if (days === 7) return "1 week";
|
|
34
|
+
return days === 1 ? "1 day" : `${days} days`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Let the user turn auto-archive on for their conversations and pick how long
|
|
39
|
+
* finished work must stay quiet before Junior archives it.
|
|
40
|
+
*/
|
|
41
|
+
export function AutoArchiveSettings({
|
|
42
|
+
identity,
|
|
43
|
+
}: {
|
|
44
|
+
identity: DashboardIdentity;
|
|
45
|
+
}) {
|
|
46
|
+
const queryClient = useQueryClient();
|
|
47
|
+
const update = useMutation({
|
|
48
|
+
mutationFn: (
|
|
49
|
+
settings: Pick<
|
|
50
|
+
DashboardIdentity,
|
|
51
|
+
"autoArchiveDefault" | "autoArchiveGraceHours"
|
|
52
|
+
>,
|
|
53
|
+
) => patch(dashboardIdentitySchema, "/api/me", settings),
|
|
54
|
+
onSuccess: (updated) => {
|
|
55
|
+
queryClient.setQueryData<DashboardCoreData>(
|
|
56
|
+
dashboardCoreQueryKey,
|
|
57
|
+
(current) => (current ? { ...current, me: updated } : current),
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const archiveDone = useArchiveDoneConversations();
|
|
62
|
+
const enabled = identity.autoArchiveDefault ?? false;
|
|
63
|
+
const graceHours = identity.autoArchiveGraceHours ?? 24;
|
|
64
|
+
// Keep a saved value that is not a preset, so the select shows the truth.
|
|
65
|
+
const graceOptions = AUTO_ARCHIVE_GRACE_OPTIONS.includes(graceHours)
|
|
66
|
+
? AUTO_ARCHIVE_GRACE_OPTIONS
|
|
67
|
+
: [...AUTO_ARCHIVE_GRACE_OPTIONS, graceHours].sort((a, b) => a - b);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Card className="mt-6 mb-0" padding="md" variant="raised">
|
|
71
|
+
<h2 className="m-0 text-lg font-bold">Conversations</h2>
|
|
72
|
+
<label className="mt-4 flex cursor-pointer items-start gap-3">
|
|
73
|
+
<Checkbox
|
|
74
|
+
checked={enabled}
|
|
75
|
+
className="mt-0.5"
|
|
76
|
+
disabled={update.isPending}
|
|
77
|
+
onCheckedChange={(checked) =>
|
|
78
|
+
update.mutate({ autoArchiveDefault: checked })
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
<span className="grid gap-0.5">
|
|
82
|
+
<span className="text-sm font-medium">
|
|
83
|
+
Auto-archive conversations when done
|
|
84
|
+
</span>
|
|
85
|
+
<span className="text-xs text-dashboard-text-muted">
|
|
86
|
+
Archive a conversation for you when all of its linked work, such as
|
|
87
|
+
pull requests, is merged or closed and nobody replies during the
|
|
88
|
+
wait time. You can change this for each conversation in its details.
|
|
89
|
+
</span>
|
|
90
|
+
</span>
|
|
91
|
+
</label>
|
|
92
|
+
<Field className="mt-5 max-w-xs">
|
|
93
|
+
<FieldLabel htmlFor="auto-archive-grace">Wait time</FieldLabel>
|
|
94
|
+
<NativeSelect
|
|
95
|
+
aria-describedby="auto-archive-grace-help"
|
|
96
|
+
disabled={update.isPending}
|
|
97
|
+
id="auto-archive-grace"
|
|
98
|
+
onChange={(event) =>
|
|
99
|
+
update.mutate({ autoArchiveGraceHours: Number(event.target.value) })
|
|
100
|
+
}
|
|
101
|
+
value={graceHours}
|
|
102
|
+
>
|
|
103
|
+
{graceOptions.map((hours) => (
|
|
104
|
+
<option key={hours} value={hours}>
|
|
105
|
+
{formatGraceHours(hours)}
|
|
106
|
+
</option>
|
|
107
|
+
))}
|
|
108
|
+
</NativeSelect>
|
|
109
|
+
<FieldDescription id="auto-archive-grace-help">
|
|
110
|
+
How long finished work and the conversation stay quiet before archive.
|
|
111
|
+
</FieldDescription>
|
|
112
|
+
</Field>
|
|
113
|
+
{update.isError ? (
|
|
114
|
+
<InlineError className="mt-3">
|
|
115
|
+
Could not save this setting. Try again.
|
|
116
|
+
</InlineError>
|
|
117
|
+
) : null}
|
|
118
|
+
<div className="mt-5 grid gap-1.5">
|
|
119
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
120
|
+
<Button
|
|
121
|
+
disabled={archiveDone.isPending}
|
|
122
|
+
onClick={() => archiveDone.mutate()}
|
|
123
|
+
type="button"
|
|
124
|
+
>
|
|
125
|
+
{archiveDone.isPending ? "Archiving…" : "Archive done now"}
|
|
126
|
+
</Button>
|
|
127
|
+
{archiveDone.isSuccess ? (
|
|
128
|
+
<p className="m-0 text-sm text-dashboard-text-muted" role="status">
|
|
129
|
+
{formatArchivedCount(archiveDone.data.archived)}
|
|
130
|
+
</p>
|
|
131
|
+
) : null}
|
|
132
|
+
{archiveDone.isError ? (
|
|
133
|
+
<InlineError>
|
|
134
|
+
Could not archive conversations. Try again.
|
|
135
|
+
</InlineError>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
139
|
+
Archive every done conversation once, without the wait time. Skips
|
|
140
|
+
conversations where you turned auto-archive off.
|
|
141
|
+
</p>
|
|
142
|
+
</div>
|
|
143
|
+
</Card>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -24,6 +24,7 @@ import { InlineError } from "../../components/InlineError";
|
|
|
24
24
|
import { Card } from "../../components/layout/Card";
|
|
25
25
|
import { DashboardApiError, fetchDashboardJson, patch, put } from "../../http";
|
|
26
26
|
import type { DashboardCoreData } from "../../types";
|
|
27
|
+
import { AutoArchiveSettings } from "./AutoArchiveSettings";
|
|
27
28
|
import { SettingsPageLayout } from "./SettingsPageLayout";
|
|
28
29
|
|
|
29
30
|
const dashboardCoreQueryKey = ["dashboard", "core"] as const;
|
|
@@ -108,6 +109,7 @@ export function SettingsPage({ identity }: SettingsPageProps) {
|
|
|
108
109
|
</form>
|
|
109
110
|
<ConversationStartSetting />
|
|
110
111
|
<ComposerAutocorrectSetting />
|
|
112
|
+
<AutoArchiveSettings identity={identity} />
|
|
111
113
|
<CustomizationForm />
|
|
112
114
|
</SettingsPageLayout>
|
|
113
115
|
);
|
|
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
4
4
|
|
|
5
5
|
import { LoadMorePagination } from "../components/Pagination";
|
|
6
|
+
import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
|
|
6
7
|
import {
|
|
7
8
|
DetailsCard,
|
|
8
9
|
DetailsList,
|
|
@@ -74,9 +75,9 @@ function ReviewItemRow(props: {
|
|
|
74
75
|
if (props.defaultExpanded) setExpanded(true);
|
|
75
76
|
}, [props.defaultExpanded]);
|
|
76
77
|
return (
|
|
77
|
-
|
|
78
|
+
<Collapsible className="min-w-0" onOpenChange={setExpanded} open={expanded}>
|
|
78
79
|
<DetailsRow
|
|
79
|
-
|
|
80
|
+
disclosure
|
|
80
81
|
icon={<Inbox aria-hidden="true" className="size-3.5" />}
|
|
81
82
|
meta={
|
|
82
83
|
<span className="grid size-5 place-items-center">
|
|
@@ -86,14 +87,11 @@ function ReviewItemRow(props: {
|
|
|
86
87
|
/>
|
|
87
88
|
</span>
|
|
88
89
|
}
|
|
89
|
-
onToggle={() => setExpanded((value) => !value)}
|
|
90
90
|
title={props.item.title}
|
|
91
91
|
/>
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
) : null}
|
|
97
|
-
</>
|
|
92
|
+
<CollapsibleContent className="px-1.5 pb-1">
|
|
93
|
+
<ReviewItemCard item={props.item} />
|
|
94
|
+
</CollapsibleContent>
|
|
95
|
+
</Collapsible>
|
|
98
96
|
);
|
|
99
97
|
}
|
|
@@ -26,7 +26,7 @@ function actionError(error: unknown): string {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Show the submitted answer as one line for each answered field. */
|
|
29
|
-
function AnswerSummary(props: {
|
|
29
|
+
export function AnswerSummary(props: {
|
|
30
30
|
answer: NonNullable<ReviewItemPayload["answer"]>;
|
|
31
31
|
request: NonNullable<ReviewItemPayload["request"]>;
|
|
32
32
|
}) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Inbox } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Link } from "react-router";
|
|
4
|
+
import type {
|
|
5
|
+
ReviewItemChangeRecord,
|
|
6
|
+
ReviewItemPayload,
|
|
7
|
+
} from "@sentry/junior/api/schema";
|
|
8
|
+
|
|
9
|
+
import { AnswerSummary } from "./ReviewItemActions";
|
|
10
|
+
import { useReviewItemData } from "./queries";
|
|
11
|
+
import { reviewItemPath } from "./reviewItemFormat";
|
|
12
|
+
|
|
13
|
+
const CHANGE_VERBS = {
|
|
14
|
+
dismissed: "Dismissed",
|
|
15
|
+
submitted: "Answered",
|
|
16
|
+
taken: "Took",
|
|
17
|
+
} as const satisfies Record<ReviewItemChangeRecord["kind"], string>;
|
|
18
|
+
|
|
19
|
+
function ChangeRow(props: {
|
|
20
|
+
item: ReviewItemPayload;
|
|
21
|
+
record: ReviewItemChangeRecord;
|
|
22
|
+
}) {
|
|
23
|
+
const { item, record } = props;
|
|
24
|
+
const verb = CHANGE_VERBS[record.kind];
|
|
25
|
+
return (
|
|
26
|
+
<section
|
|
27
|
+
aria-label={`${verb} ${item.title}`}
|
|
28
|
+
className="grid min-w-0 max-w-[37.5rem] gap-2 rounded-lg border border-dashboard-border bg-dashboard-surface-panel p-3 font-sans text-sm"
|
|
29
|
+
>
|
|
30
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
31
|
+
<Inbox
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
className="shrink-0 text-dashboard-text-muted"
|
|
34
|
+
size={15}
|
|
35
|
+
/>
|
|
36
|
+
<span className="shrink-0 text-dashboard-text-muted">{verb}</span>
|
|
37
|
+
<Link
|
|
38
|
+
className="min-w-0 flex-1 truncate font-medium text-dashboard-text no-underline hover:underline focus-visible:outline-dashboard-focus"
|
|
39
|
+
to={reviewItemPath(item.id)}
|
|
40
|
+
>
|
|
41
|
+
{item.title}
|
|
42
|
+
</Link>
|
|
43
|
+
</div>
|
|
44
|
+
{/* An answer is final, so the item's answer is this message's answer. */}
|
|
45
|
+
{record.kind === "submitted" && item.request && item.answer ? (
|
|
46
|
+
<AnswerSummary answer={item.answer} request={item.request} />
|
|
47
|
+
) : null}
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Show the Review item change that a message records as one row: what the
|
|
54
|
+
* message did and the item it did it to. The row shows the change from the
|
|
55
|
+
* message, not the item's current state, so a take stays a take after a
|
|
56
|
+
* later answer. Shows `fallback` when the viewer cannot read the item.
|
|
57
|
+
*/
|
|
58
|
+
export function ReviewItemChangeRow(props: {
|
|
59
|
+
fallback: ReactNode;
|
|
60
|
+
record: ReviewItemChangeRecord;
|
|
61
|
+
}) {
|
|
62
|
+
const { data, isError } = useReviewItemData(props.record.reviewItemId);
|
|
63
|
+
if (isError) return props.fallback;
|
|
64
|
+
// The row replaces the text, so wait for the item before showing it.
|
|
65
|
+
if (!data) return null;
|
|
66
|
+
return <ChangeRow item={data} record={props.record} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Name the Review item change that a queued message records, on one line.
|
|
71
|
+
* Shows `fallback` until the item loads or when the viewer cannot read it.
|
|
72
|
+
*/
|
|
73
|
+
export function ReviewItemChangeLabel(props: {
|
|
74
|
+
fallback: ReactNode;
|
|
75
|
+
record: ReviewItemChangeRecord;
|
|
76
|
+
}) {
|
|
77
|
+
const { data } = useReviewItemData(props.record.reviewItemId);
|
|
78
|
+
if (!data) return props.fallback;
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
{CHANGE_VERBS[props.record.kind]}{" "}
|
|
82
|
+
<span className="font-medium text-dashboard-text">{data.title}</span>
|
|
83
|
+
</>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -9,28 +9,39 @@ import { ConversationTranscriptView } from "../conversations/ConversationTranscr
|
|
|
9
9
|
import { TranscriptLoading } from "../conversations/TranscriptLoading";
|
|
10
10
|
import { useConversationData } from "../conversations/queries";
|
|
11
11
|
import type { ConversationTranscript } from "../types";
|
|
12
|
+
import { excerptMessage } from "./reviewItemFormat";
|
|
12
13
|
|
|
13
14
|
/** Messages before the item that the excerpt shows. */
|
|
14
15
|
const BEFORE_COUNT = 3;
|
|
15
|
-
/** Messages after the item that the excerpt shows
|
|
16
|
-
const AFTER_COUNT =
|
|
16
|
+
/** Messages after the item that the excerpt shows. */
|
|
17
|
+
const AFTER_COUNT = 3;
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* Pick the messages nearest the time an item was added. Only messages show:
|
|
21
|
+
* the tool call that added the item would repeat its card. For a closed item,
|
|
22
|
+
* messages from its close on do not show: the card already has the answer.
|
|
21
23
|
*/
|
|
22
24
|
function conversationExcerpt(
|
|
23
25
|
events: readonly ConversationReportEvent[],
|
|
24
|
-
|
|
26
|
+
item: Pick<ReviewItemPayload, "closedAt" | "createdAt">,
|
|
25
27
|
): { after: ConversationReportEvent[]; before: ConversationReportEvent[] } {
|
|
26
|
-
const askedAt = Date.parse(
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
)
|
|
28
|
+
const askedAt = Date.parse(item.createdAt);
|
|
29
|
+
const closedAt = item.closedAt ? Date.parse(item.closedAt) : Infinity;
|
|
30
|
+
// Changes to items show on their cards, not as context for this one.
|
|
31
|
+
const messages = events.flatMap((event) => {
|
|
32
|
+
if (event.data.type !== "message" || event.data.role === "system") {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const message = excerptMessage(event);
|
|
36
|
+
return message ? [message] : [];
|
|
37
|
+
});
|
|
30
38
|
return {
|
|
31
39
|
after: messages
|
|
32
|
-
.filter((event) =>
|
|
33
|
-
|
|
40
|
+
.filter((event) => {
|
|
41
|
+
const at = Date.parse(event.createdAt);
|
|
42
|
+
return at > askedAt && at < closedAt;
|
|
43
|
+
})
|
|
44
|
+
.slice(0, AFTER_COUNT),
|
|
34
45
|
before: messages
|
|
35
46
|
.filter((event) => Date.parse(event.createdAt) <= askedAt)
|
|
36
47
|
.slice(-BEFORE_COUNT),
|
|
@@ -49,8 +60,8 @@ function Excerpt(props: {
|
|
|
49
60
|
}
|
|
50
61
|
|
|
51
62
|
/**
|
|
52
|
-
* Show the
|
|
53
|
-
* asked. Returns `fallback` when the viewer cannot read its messages.
|
|
63
|
+
* Show the messages around the point where the agent added an item, split
|
|
64
|
+
* where it asked. Returns `fallback` when the viewer cannot read its messages.
|
|
54
65
|
*/
|
|
55
66
|
export function ReviewItemContext(props: {
|
|
56
67
|
fallback: ReactNode;
|
|
@@ -60,30 +71,37 @@ export function ReviewItemContext(props: {
|
|
|
60
71
|
const data = detail.data;
|
|
61
72
|
const events = data?.events;
|
|
62
73
|
const excerpt = useMemo(
|
|
63
|
-
() =>
|
|
64
|
-
|
|
65
|
-
[events, props.item.createdAt],
|
|
74
|
+
() => (events ? conversationExcerpt(events, props.item) : undefined),
|
|
75
|
+
[events, props.item],
|
|
66
76
|
);
|
|
67
77
|
// The first page holds the newest events. Load earlier pages until the
|
|
68
|
-
// page reaches the time the item was added, or history ends.
|
|
78
|
+
// page reaches the time the item was added, or history ends. Until then
|
|
79
|
+
// the loaded messages after the ask are not the first ones, so the
|
|
80
|
+
// excerpt waits.
|
|
69
81
|
const loadPreviousPageRef = useRef(detail.loadPreviousPage);
|
|
70
82
|
loadPreviousPageRef.current = detail.loadPreviousPage;
|
|
71
83
|
const oldest = events?.[0]?.createdAt;
|
|
72
|
-
|
|
84
|
+
// Redacted or expired history has no messages to wait for.
|
|
85
|
+
const available = data?.eventHistory.status === "available";
|
|
86
|
+
const missingEarlier =
|
|
87
|
+
available &&
|
|
73
88
|
oldest !== undefined &&
|
|
74
89
|
Date.parse(oldest) > Date.parse(props.item.createdAt) &&
|
|
75
|
-
detail.hasPreviousPage
|
|
76
|
-
|
|
77
|
-
!detail.historyError;
|
|
90
|
+
detail.hasPreviousPage;
|
|
91
|
+
const loadEarlier =
|
|
92
|
+
missingEarlier && !detail.isLoadingPreviousPage && !detail.historyError;
|
|
78
93
|
useEffect(() => {
|
|
79
94
|
if (loadEarlier) loadPreviousPageRef.current();
|
|
80
95
|
}, [loadEarlier, oldest]);
|
|
81
96
|
|
|
82
|
-
if (detail.isPending
|
|
97
|
+
if (detail.isPending || (missingEarlier && !detail.historyError)) {
|
|
98
|
+
return <TranscriptLoading />;
|
|
99
|
+
}
|
|
83
100
|
if (
|
|
84
101
|
!data ||
|
|
85
102
|
!excerpt ||
|
|
86
|
-
|
|
103
|
+
!available ||
|
|
104
|
+
missingEarlier ||
|
|
87
105
|
excerpt.before.length + excerpt.after.length === 0
|
|
88
106
|
) {
|
|
89
107
|
return props.fallback;
|
|
@@ -20,7 +20,8 @@ import {
|
|
|
20
20
|
import { conversationDetailTitle } from "../format";
|
|
21
21
|
import { fetchDashboardJson, post } from "../http";
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/** Prefix of every Review item read, for refreshes after a change. */
|
|
24
|
+
export const REVIEW_ITEMS_QUERY_KEY = ["dashboard", "review-items"] as const;
|
|
24
25
|
const COUNT_REFRESH_MS = 60_000;
|
|
25
26
|
|
|
26
27
|
/**
|