@sentry/junior-dashboard 0.58.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/LICENSE +201 -0
- package/dist/app.d.ts +22 -0
- package/dist/app.js +492 -0
- package/dist/auth.d.ts +30 -0
- package/dist/client/App.d.ts +2 -0
- package/dist/client/api.d.ts +10 -0
- package/dist/client/code.d.ts +15 -0
- package/dist/client/components/CommandRail.d.ts +6 -0
- package/dist/client/components/ConversationList.d.ts +7 -0
- package/dist/client/components/ConversationRowStats.d.ts +6 -0
- package/dist/client/components/ConversationStack.d.ts +5 -0
- package/dist/client/components/ConversationSummary.d.ts +5 -0
- package/dist/client/components/EmptyTelemetry.d.ts +5 -0
- package/dist/client/components/FilterTabs.d.ts +6 -0
- package/dist/client/components/JuniorLogo.d.ts +2 -0
- package/dist/client/components/LoadingView.d.ts +4 -0
- package/dist/client/components/Section.d.ts +6 -0
- package/dist/client/components/SectionHeader.d.ts +6 -0
- package/dist/client/components/SectionTitle.d.ts +5 -0
- package/dist/client/components/StatusBadge.d.ts +6 -0
- package/dist/client/components/ToolFrame.d.ts +10 -0
- package/dist/client/components/Transcript.d.ts +5 -0
- package/dist/client/components/TranscriptHeader.d.ts +7 -0
- package/dist/client/components/TranscriptLoading.d.ts +2 -0
- package/dist/client/components/TranscriptText.d.ts +6 -0
- package/dist/client/components/TranscriptToolView.d.ts +9 -0
- package/dist/client/components/TranscriptTurn.d.ts +8 -0
- package/dist/client/components/TurnDurationChart.d.ts +6 -0
- package/dist/client/components/statusStyles.d.ts +3 -0
- package/dist/client/components/transcriptPreview.d.ts +4 -0
- package/dist/client/components/transcriptRenderModel.d.ts +30 -0
- package/dist/client/components/transcriptStyles.d.ts +4 -0
- package/dist/client/format.d.ts +64 -0
- package/dist/client/pages/CommandCenter.d.ts +6 -0
- package/dist/client/pages/ConversationPage.d.ts +5 -0
- package/dist/client/pages/ConversationsPage.d.ts +5 -0
- package/dist/client/styles.d.ts +2 -0
- package/dist/client/types.d.ts +153 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +56012 -0
- package/dist/config.d.ts +4 -0
- package/dist/handler.d.ts +2 -0
- package/dist/handler.js +563 -0
- package/dist/nitro.d.ts +16 -0
- package/dist/nitro.js +80 -0
- package/dist/tailwind.css +2 -0
- package/package.json +60 -0
- package/src/app.ts +421 -0
- package/src/auth.ts +202 -0
- package/src/client/App.tsx +175 -0
- package/src/client/api.ts +99 -0
- package/src/client/code.tsx +139 -0
- package/src/client/components/CommandRail.tsx +74 -0
- package/src/client/components/ConversationList.tsx +94 -0
- package/src/client/components/ConversationRowStats.tsx +21 -0
- package/src/client/components/ConversationStack.tsx +67 -0
- package/src/client/components/ConversationSummary.tsx +40 -0
- package/src/client/components/EmptyTelemetry.tsx +11 -0
- package/src/client/components/FilterTabs.tsx +35 -0
- package/src/client/components/JuniorLogo.tsx +8 -0
- package/src/client/components/LoadingView.tsx +16 -0
- package/src/client/components/Section.tsx +17 -0
- package/src/client/components/SectionHeader.tsx +16 -0
- package/src/client/components/SectionTitle.tsx +10 -0
- package/src/client/components/StatusBadge.tsx +40 -0
- package/src/client/components/ToolFrame.tsx +52 -0
- package/src/client/components/Transcript.tsx +39 -0
- package/src/client/components/TranscriptHeader.tsx +52 -0
- package/src/client/components/TranscriptLoading.tsx +10 -0
- package/src/client/components/TranscriptText.tsx +45 -0
- package/src/client/components/TranscriptToolView.tsx +207 -0
- package/src/client/components/TranscriptTurn.tsx +487 -0
- package/src/client/components/TurnDurationChart.tsx +349 -0
- package/src/client/components/statusStyles.ts +9 -0
- package/src/client/components/transcriptPreview.ts +20 -0
- package/src/client/components/transcriptRenderModel.ts +193 -0
- package/src/client/components/transcriptStyles.ts +11 -0
- package/src/client/format.ts +628 -0
- package/src/client/pages/CommandCenter.tsx +37 -0
- package/src/client/pages/ConversationPage.tsx +145 -0
- package/src/client/pages/ConversationsPage.tsx +59 -0
- package/src/client/styles.ts +6 -0
- package/src/client/types.ts +153 -0
- package/src/client.tsx +79 -0
- package/src/config.ts +72 -0
- package/src/handler.ts +26 -0
- package/src/nitro.ts +110 -0
- package/src/tailwind.css +13 -0
- package/src/virtual-modules.d.ts +5 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useNavigate } from "react-router";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
conversationPath,
|
|
5
|
+
formatTime,
|
|
6
|
+
visualStatusForConversation,
|
|
7
|
+
} from "../format";
|
|
8
|
+
import { cn } from "../styles";
|
|
9
|
+
import type { Conversation, VisualStatus } from "../types";
|
|
10
|
+
import { ConversationRowStats } from "./ConversationRowStats";
|
|
11
|
+
import { ConversationSummary } from "./ConversationSummary";
|
|
12
|
+
import { EmptyTelemetry } from "./EmptyTelemetry";
|
|
13
|
+
import { statusBorderClass } from "./statusStyles";
|
|
14
|
+
|
|
15
|
+
/** Render the full conversation table used by the conversations page. */
|
|
16
|
+
export function ConversationList(props: {
|
|
17
|
+
conversations: Conversation[];
|
|
18
|
+
selectedId?: string;
|
|
19
|
+
search?: string;
|
|
20
|
+
}) {
|
|
21
|
+
if (props.conversations.length === 0) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="grid gap-2 p-3">
|
|
24
|
+
<EmptyTelemetry>No matching conversation telemetry.</EmptyTelemetry>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="min-w-0" role="table">
|
|
31
|
+
<div
|
|
32
|
+
className="sticky top-0 z-[1] grid grid-cols-[minmax(13rem,1.7fr)_minmax(13rem,1fr)] items-center gap-3 border-b border-white/10 bg-[#050505] px-3 py-2 text-[0.76rem] font-semibold uppercase leading-none text-[#888] max-md:hidden"
|
|
33
|
+
role="row"
|
|
34
|
+
>
|
|
35
|
+
<div>Conversation</div>
|
|
36
|
+
<div className="justify-self-end">Stats</div>
|
|
37
|
+
</div>
|
|
38
|
+
{props.conversations.map((conversation) => (
|
|
39
|
+
<ConversationTableRow
|
|
40
|
+
conversation={conversation}
|
|
41
|
+
key={conversation.id}
|
|
42
|
+
search={props.search}
|
|
43
|
+
selected={props.selectedId === conversation.id}
|
|
44
|
+
/>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ConversationTableRow(props: {
|
|
51
|
+
conversation: Conversation;
|
|
52
|
+
search?: string;
|
|
53
|
+
selected?: boolean;
|
|
54
|
+
}) {
|
|
55
|
+
const visualStatus = visualStatusForConversation(props.conversation);
|
|
56
|
+
const navigate = useNavigate();
|
|
57
|
+
const href = {
|
|
58
|
+
pathname: conversationPath(props.conversation.id),
|
|
59
|
+
search: props.search ?? "",
|
|
60
|
+
};
|
|
61
|
+
const openConversation = () => navigate(href);
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
className={conversationRecordClass(visualStatus, props.selected)}
|
|
65
|
+
onClick={openConversation}
|
|
66
|
+
onKeyDown={(event) => {
|
|
67
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
openConversation();
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
role="link"
|
|
73
|
+
tabIndex={0}
|
|
74
|
+
>
|
|
75
|
+
<ConversationSummary conversation={props.conversation} />
|
|
76
|
+
<ConversationRowStats
|
|
77
|
+
conversation={props.conversation}
|
|
78
|
+
timeLabel={formatTime(props.conversation.lastSeenAt)}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function conversationRecordClass(
|
|
85
|
+
status: VisualStatus,
|
|
86
|
+
selected: boolean | undefined,
|
|
87
|
+
): string {
|
|
88
|
+
return cn(
|
|
89
|
+
"group grid min-w-0 cursor-pointer grid-cols-[minmax(13rem,1.7fr)_minmax(13rem,1fr)] items-center gap-3 overflow-hidden border-b border-l-4 border-b-white/10 bg-[#0b0b0b] px-3 py-3 text-left text-inherit no-underline transition-colors hover:bg-[#151515] max-md:grid-cols-1 max-md:px-4 max-md:py-4",
|
|
90
|
+
statusBorderClass(status),
|
|
91
|
+
status === "idle" && "saturate-50",
|
|
92
|
+
selected && "border-l-white bg-[#111]",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { slackLocationLabel } from "../format";
|
|
2
|
+
import type { Conversation } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Render compact conversation metadata aligned to row context. */
|
|
5
|
+
export function ConversationRowStats(props: {
|
|
6
|
+
conversation: Conversation;
|
|
7
|
+
timeLabel: string;
|
|
8
|
+
}) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="grid min-w-0 justify-items-end gap-1 text-right max-md:justify-items-start max-md:text-left">
|
|
11
|
+
<div className="text-[0.84rem] leading-relaxed text-[#b8b8b8]">
|
|
12
|
+
{props.conversation.turns.length} turns · {props.timeLabel}
|
|
13
|
+
</div>
|
|
14
|
+
{props.conversation.channel ? (
|
|
15
|
+
<div className="max-w-full break-words text-[0.84rem] leading-relaxed text-[#888] md:truncate">
|
|
16
|
+
{slackLocationLabel(props.conversation, { includeId: false })}
|
|
17
|
+
</div>
|
|
18
|
+
) : null}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useNavigate } from "react-router";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
conversationPath,
|
|
5
|
+
formatRelativeTime,
|
|
6
|
+
visualStatusForConversation,
|
|
7
|
+
} from "../format";
|
|
8
|
+
import { cn } from "../styles";
|
|
9
|
+
import type { Conversation, VisualStatus } from "../types";
|
|
10
|
+
import { ConversationRowStats } from "./ConversationRowStats";
|
|
11
|
+
import { ConversationSummary } from "./ConversationSummary";
|
|
12
|
+
import { EmptyTelemetry } from "./EmptyTelemetry";
|
|
13
|
+
|
|
14
|
+
/** Render the compact latest-conversation stack on the command center. */
|
|
15
|
+
export function ConversationStack(props: { conversations: Conversation[] }) {
|
|
16
|
+
if (props.conversations.length === 0) {
|
|
17
|
+
return <EmptyTelemetry>No conversation telemetry yet.</EmptyTelemetry>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="grid">
|
|
22
|
+
{props.conversations.map((conversation) => (
|
|
23
|
+
<ConversationStackRow
|
|
24
|
+
conversation={conversation}
|
|
25
|
+
key={conversation.id}
|
|
26
|
+
/>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ConversationStackRow(props: { conversation: Conversation }) {
|
|
33
|
+
const visualStatus = visualStatusForConversation(props.conversation);
|
|
34
|
+
const navigate = useNavigate();
|
|
35
|
+
const href = conversationPath(props.conversation.id);
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={conversationStackRowClass(visualStatus)}
|
|
39
|
+
onClick={() => navigate(href)}
|
|
40
|
+
onKeyDown={(event) => {
|
|
41
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
navigate(href);
|
|
44
|
+
}
|
|
45
|
+
}}
|
|
46
|
+
role="link"
|
|
47
|
+
tabIndex={0}
|
|
48
|
+
>
|
|
49
|
+
<ConversationSummary conversation={props.conversation} />
|
|
50
|
+
<ConversationRowStats
|
|
51
|
+
conversation={props.conversation}
|
|
52
|
+
timeLabel={formatRelativeTime(props.conversation.lastSeenAt)}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function conversationStackRowClass(status: VisualStatus): string {
|
|
59
|
+
return cn(
|
|
60
|
+
"group relative grid min-h-16 cursor-pointer grid-cols-[minmax(0,1fr)_minmax(12rem,max-content)] items-center gap-3 overflow-hidden border-b border-l-4 border-b-white/10 bg-[#050505] px-4 py-3 text-inherit no-underline transition-colors last:border-b-0 hover:bg-[rgba(190,170,255,0.07)] max-md:grid-cols-1",
|
|
61
|
+
status === "active" && "border-l-emerald-400",
|
|
62
|
+
status === "hung" && "border-l-amber-400",
|
|
63
|
+
status === "failed" && "border-l-rose-400",
|
|
64
|
+
status === "idle" && "border-l-[#beaaff]/60",
|
|
65
|
+
status === "idle" && "saturate-50",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
conversationDisplayTitle,
|
|
3
|
+
conversationIdentityMeta,
|
|
4
|
+
visualStatusForConversation,
|
|
5
|
+
} from "../format";
|
|
6
|
+
import type { Conversation } from "../types";
|
|
7
|
+
import { StatusBadge } from "./StatusBadge";
|
|
8
|
+
|
|
9
|
+
/** Render the shared conversation title, identity, status, and Sentry link. */
|
|
10
|
+
export function ConversationSummary(props: { conversation: Conversation }) {
|
|
11
|
+
const visualStatus = visualStatusForConversation(props.conversation);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="min-w-0">
|
|
15
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
16
|
+
<div className="min-w-0 truncate text-[1.04rem] font-bold leading-tight text-white">
|
|
17
|
+
{conversationDisplayTitle(props.conversation)}
|
|
18
|
+
</div>
|
|
19
|
+
<StatusBadge status={visualStatus} />
|
|
20
|
+
</div>
|
|
21
|
+
<div className="mt-1 break-words text-[0.86rem] leading-relaxed text-[#b8b8b8] md:truncate">
|
|
22
|
+
{conversationIdentityMeta(props.conversation, props.conversation.id)}
|
|
23
|
+
{props.conversation.sentryConversationUrl ? (
|
|
24
|
+
<>
|
|
25
|
+
{" · "}
|
|
26
|
+
<a
|
|
27
|
+
className="border-b border-white/30 text-[0.86rem] leading-relaxed text-white no-underline hover:border-white"
|
|
28
|
+
href={props.conversation.sentryConversationUrl}
|
|
29
|
+
onClick={(event) => event.stopPropagation()}
|
|
30
|
+
rel="noreferrer"
|
|
31
|
+
target="_blank"
|
|
32
|
+
>
|
|
33
|
+
View in Sentry
|
|
34
|
+
</a>
|
|
35
|
+
</>
|
|
36
|
+
) : null}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Render the dashboard empty-state block with a warning accent. */
|
|
4
|
+
export function EmptyTelemetry(props: { children: ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="relative min-w-0 border border-white/10 bg-[#050505] px-4 py-3 pl-5 text-[0.9rem] leading-relaxed text-[#b8b8b8]">
|
|
7
|
+
<span className="absolute bottom-0 left-0 top-0 w-1 bg-amber-400" />
|
|
8
|
+
{props.children}
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cn } from "../styles";
|
|
2
|
+
import type { SessionFilter } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Render conversation filters while keeping URL state owned by the page. */
|
|
5
|
+
export function FilterTabs(props: {
|
|
6
|
+
current: SessionFilter;
|
|
7
|
+
onChange(filter: SessionFilter): void;
|
|
8
|
+
}) {
|
|
9
|
+
const filters: SessionFilter[] = [
|
|
10
|
+
"recent",
|
|
11
|
+
"active",
|
|
12
|
+
"hung",
|
|
13
|
+
"failed",
|
|
14
|
+
"all",
|
|
15
|
+
];
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-wrap items-center justify-end gap-1">
|
|
18
|
+
{filters.map((filter) => (
|
|
19
|
+
<button
|
|
20
|
+
className={cn(
|
|
21
|
+
"cursor-pointer border px-2 py-1 text-[0.78rem] font-semibold uppercase leading-tight transition-colors",
|
|
22
|
+
props.current === filter
|
|
23
|
+
? "border-white/30 bg-white text-black"
|
|
24
|
+
: "border-white/10 bg-[#0b0b0b] text-[#888] hover:border-white/25 hover:bg-[#151515] hover:text-white",
|
|
25
|
+
)}
|
|
26
|
+
key={filter}
|
|
27
|
+
onClick={() => props.onChange(filter)}
|
|
28
|
+
type="button"
|
|
29
|
+
>
|
|
30
|
+
{filter}
|
|
31
|
+
</button>
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Render the compact Junior wordmark used by the dashboard shell. */
|
|
2
|
+
export function JuniorLogo() {
|
|
3
|
+
return (
|
|
4
|
+
<div className="grid size-9 shrink-0 select-none place-items-center bg-black text-[0.82rem] font-black leading-none text-white">
|
|
5
|
+
Jr
|
|
6
|
+
</div>
|
|
7
|
+
);
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JuniorLogo } from "./JuniorLogo";
|
|
2
|
+
|
|
3
|
+
/** Render the full-page loading treatment before the first dashboard payload lands. */
|
|
4
|
+
export function LoadingView(props: { label: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="grid min-h-[calc(100vh-5rem)] place-items-center px-4 py-8 md:px-8">
|
|
7
|
+
<section className="grid w-full max-w-lg grid-cols-[auto_minmax(0,1fr)] items-center gap-3 border border-white/15 bg-[#0b0b0b] p-4">
|
|
8
|
+
<JuniorLogo />
|
|
9
|
+
<div>
|
|
10
|
+
<div className="font-bold">{props.label}</div>
|
|
11
|
+
<div className="mt-3 h-1.5 w-full animate-pulse bg-white/20" />
|
|
12
|
+
</div>
|
|
13
|
+
</section>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../styles";
|
|
4
|
+
|
|
5
|
+
/** Frame a dashboard content region without leaking CSS class contracts. */
|
|
6
|
+
export function Section(props: { children: ReactNode; className?: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<section
|
|
9
|
+
className={cn(
|
|
10
|
+
"mb-4 min-w-0 border border-white/10 bg-[#0b0b0b]",
|
|
11
|
+
props.className,
|
|
12
|
+
)}
|
|
13
|
+
>
|
|
14
|
+
{props.children}
|
|
15
|
+
</section>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Render a dashboard section heading row with optional controls. */
|
|
4
|
+
export function SectionHeader(props: {
|
|
5
|
+
actions?: ReactNode;
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-[#111] px-4 py-3 max-md:flex-col max-md:items-stretch">
|
|
10
|
+
<div className="min-w-0">{props.children}</div>
|
|
11
|
+
{props.actions ? (
|
|
12
|
+
<div className="shrink-0 max-md:w-full">{props.actions}</div>
|
|
13
|
+
) : null}
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Render compact section titles that fit inside operational panels. */
|
|
4
|
+
export function SectionTitle(props: { children: ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="min-w-0 break-words text-[1.05rem] font-bold leading-tight tracking-normal">
|
|
7
|
+
{props.children}
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cn } from "../styles";
|
|
2
|
+
import type { VisualStatus } from "../types";
|
|
3
|
+
|
|
4
|
+
/** Render readable status text while keeping severity color restrained. */
|
|
5
|
+
export function StatusBadge(props: {
|
|
6
|
+
label?: string;
|
|
7
|
+
status: VisualStatus | undefined;
|
|
8
|
+
}) {
|
|
9
|
+
const status = props.status ?? "idle";
|
|
10
|
+
return (
|
|
11
|
+
<span
|
|
12
|
+
className={cn(
|
|
13
|
+
"inline-flex items-center gap-1.5",
|
|
14
|
+
statusBadgeClass(status),
|
|
15
|
+
)}
|
|
16
|
+
>
|
|
17
|
+
<span
|
|
18
|
+
className={cn(
|
|
19
|
+
"size-1.5 shrink-0",
|
|
20
|
+
status === "active" && "bg-emerald-300",
|
|
21
|
+
status === "hung" && "bg-amber-300",
|
|
22
|
+
status === "failed" && "bg-rose-300",
|
|
23
|
+
status === "idle" && "bg-white/35",
|
|
24
|
+
)}
|
|
25
|
+
/>
|
|
26
|
+
{props.label ?? status}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function statusBadgeClass(status: VisualStatus): string {
|
|
32
|
+
return cn(
|
|
33
|
+
"border px-1.5 py-0.5 text-[0.68rem] font-bold uppercase leading-none",
|
|
34
|
+
status === "active" &&
|
|
35
|
+
"border-emerald-400/25 bg-emerald-400/10 text-emerald-300",
|
|
36
|
+
status === "hung" && "border-amber-400/25 bg-amber-400/10 text-amber-300",
|
|
37
|
+
status === "failed" && "border-rose-400/25 bg-rose-400/10 text-rose-300",
|
|
38
|
+
status === "idle" && "border-white/10 bg-white/[0.03] text-[#888]",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../styles";
|
|
4
|
+
|
|
5
|
+
/** Render the shared expandable/non-expandable frame for transcript tools. */
|
|
6
|
+
export function ToolFrame(props: {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
meta: string[];
|
|
9
|
+
raw?: boolean;
|
|
10
|
+
signature: ReactNode;
|
|
11
|
+
}) {
|
|
12
|
+
const header = (
|
|
13
|
+
<>
|
|
14
|
+
<span className="flex min-w-0 flex-wrap items-baseline gap-x-1 gap-y-0.5 overflow-hidden">
|
|
15
|
+
{props.signature}
|
|
16
|
+
</span>
|
|
17
|
+
<span className="min-w-0 break-words text-right text-[0.8rem] text-[#888] max-md:text-left">
|
|
18
|
+
{props.meta.join(" · ")}
|
|
19
|
+
</span>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (props.raw) {
|
|
24
|
+
return (
|
|
25
|
+
<div className={toolFrameClass()}>
|
|
26
|
+
<div className={toolHeaderClass(false)}>{header}</div>
|
|
27
|
+
{props.children}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<details className={toolFrameClass()}>
|
|
34
|
+
<summary className={toolHeaderClass(true)}>{header}</summary>
|
|
35
|
+
{props.children}
|
|
36
|
+
</details>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Provide the shared transcript tool-frame shell for nonstandard part views. */
|
|
41
|
+
export function toolFrameClass(): string {
|
|
42
|
+
return "border border-[#beaaff]/20 bg-[#111] transition-colors hover:border-[#beaaff]/45 hover:bg-[rgba(190,170,255,0.06)]";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toolHeaderClass(interactive: boolean): string {
|
|
46
|
+
return cn(
|
|
47
|
+
"grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 font-mono text-[0.84rem] leading-tight text-[#b8b8b8] max-md:grid-cols-1 max-md:gap-1",
|
|
48
|
+
interactive
|
|
49
|
+
? "cursor-pointer hover:bg-[rgba(190,170,255,0.07)]"
|
|
50
|
+
: "cursor-default",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ConversationTurn } from "../types";
|
|
4
|
+
import { TranscriptHeader } from "./TranscriptHeader";
|
|
5
|
+
import { TurnTranscript } from "./TranscriptTurn";
|
|
6
|
+
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
7
|
+
import { transcriptEmptyClass } from "./transcriptStyles";
|
|
8
|
+
|
|
9
|
+
/** Render ordered conversation turns as message, thinking, and tool-call events. */
|
|
10
|
+
export function Transcript(props: { turns: ConversationTurn[] }) {
|
|
11
|
+
const [view, setView] = useState<TranscriptViewMode>("rich");
|
|
12
|
+
const hasRedactedTurns = props.turns.some((turn) => turn.transcriptRedacted);
|
|
13
|
+
|
|
14
|
+
if (props.turns.length === 0) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={transcriptEmptyClass()}>
|
|
17
|
+
No transcript is available for this conversation.
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="grid min-w-0">
|
|
24
|
+
<TranscriptHeader
|
|
25
|
+
redacted={hasRedactedTurns}
|
|
26
|
+
value={view}
|
|
27
|
+
onChange={setView}
|
|
28
|
+
/>
|
|
29
|
+
{props.turns.map((turn, index) => (
|
|
30
|
+
<TurnTranscript
|
|
31
|
+
key={turn.id}
|
|
32
|
+
number={index + 1}
|
|
33
|
+
turn={turn}
|
|
34
|
+
view={view}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
2
|
+
|
|
3
|
+
/** Render transcript controls without coupling them to turn rendering. */
|
|
4
|
+
export function TranscriptHeader(props: {
|
|
5
|
+
onChange(value: TranscriptViewMode): void;
|
|
6
|
+
redacted: boolean;
|
|
7
|
+
value: TranscriptViewMode;
|
|
8
|
+
}) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="mb-1 flex min-w-0 items-start justify-between gap-3 border-b border-[#beaaff]/20 pb-3 leading-none max-md:flex-col">
|
|
11
|
+
<div className="min-w-0">
|
|
12
|
+
<div className="text-[0.78rem] font-semibold uppercase text-[#888]">
|
|
13
|
+
Transcript
|
|
14
|
+
</div>
|
|
15
|
+
{props.redacted ? (
|
|
16
|
+
<div className="mt-2 break-words text-[0.88rem] leading-relaxed text-[#b8b8b8]">
|
|
17
|
+
Hidden because this conversation is not public.
|
|
18
|
+
</div>
|
|
19
|
+
) : null}
|
|
20
|
+
</div>
|
|
21
|
+
<TranscriptViewToggle value={props.value} onChange={props.onChange} />
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function TranscriptViewToggle(props: {
|
|
27
|
+
onChange(value: TranscriptViewMode): void;
|
|
28
|
+
value: TranscriptViewMode;
|
|
29
|
+
}) {
|
|
30
|
+
const options: TranscriptViewMode[] = ["rich", "raw"];
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className="inline-flex items-center gap-1 text-[0.82rem] font-semibold text-[#888]"
|
|
34
|
+
aria-label="Transcript view"
|
|
35
|
+
>
|
|
36
|
+
{options.map((option) => (
|
|
37
|
+
<button
|
|
38
|
+
className={`cursor-pointer border-0 bg-transparent px-1.5 py-1 uppercase tracking-normal underline-offset-4 ${
|
|
39
|
+
props.value === option
|
|
40
|
+
? "text-white underline decoration-white"
|
|
41
|
+
: "text-[#888] hover:text-white"
|
|
42
|
+
}`}
|
|
43
|
+
key={option}
|
|
44
|
+
onClick={() => props.onChange(option)}
|
|
45
|
+
type="button"
|
|
46
|
+
>
|
|
47
|
+
{option}
|
|
48
|
+
</button>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Render a transcript-shaped loading state for route transitions. */
|
|
2
|
+
export function TranscriptLoading() {
|
|
3
|
+
return (
|
|
4
|
+
<div className="grid gap-3">
|
|
5
|
+
<div className="min-h-28 animate-pulse border border-white/10 bg-[#0b0b0b]" />
|
|
6
|
+
<div className="min-h-[4.5rem] animate-pulse border border-white/10 bg-[#0b0b0b]" />
|
|
7
|
+
<div className="min-h-28 animate-pulse border border-white/10 bg-[#0b0b0b]" />
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countStructuredBlockChildren,
|
|
3
|
+
HighlightedCode,
|
|
4
|
+
StructuredMarkup,
|
|
5
|
+
} from "../code";
|
|
6
|
+
import { canRenderStructuredMarkup, parseMarkdownBlocks } from "../format";
|
|
7
|
+
|
|
8
|
+
/** Render transcript markdown/code blocks with structured markup expansion. */
|
|
9
|
+
export function TranscriptText(props: {
|
|
10
|
+
firstChildIndex: number;
|
|
11
|
+
lastChildIndex: number;
|
|
12
|
+
text: string;
|
|
13
|
+
}) {
|
|
14
|
+
const blocks = parseMarkdownBlocks(props.text);
|
|
15
|
+
let seenChildren = props.firstChildIndex;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="grid min-w-0 gap-2">
|
|
19
|
+
{blocks.map((block, index) => {
|
|
20
|
+
const firstChildIndex = seenChildren;
|
|
21
|
+
const childCount = countStructuredBlockChildren(block);
|
|
22
|
+
seenChildren += childCount;
|
|
23
|
+
|
|
24
|
+
if (!canRenderStructuredMarkup(block.language)) {
|
|
25
|
+
return (
|
|
26
|
+
<HighlightedCode
|
|
27
|
+
code={block.code}
|
|
28
|
+
key={index}
|
|
29
|
+
language={block.language}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<StructuredMarkup
|
|
36
|
+
block={block}
|
|
37
|
+
firstChildIndex={firstChildIndex}
|
|
38
|
+
key={index}
|
|
39
|
+
lastChildIndex={props.lastChildIndex}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|