@sentry/junior-dashboard 0.244.0 → 0.246.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 +64 -7
- package/dist/client.js +43 -40
- package/dist/index.js +64 -7
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +3 -2
- package/src/client/components/SegmentedTabs.tsx +11 -9
- package/src/client/components/layout/PageHeader.tsx +12 -1
- package/src/client/conversations/ConversationAgentTree.tsx +42 -0
- package/src/client/conversations/ConversationChildAgents.tsx +134 -58
- package/src/client/conversations/ConversationComposer.tsx +68 -35
- package/src/client/conversations/ConversationDetailsDrawer.tsx +29 -9
- package/src/client/conversations/ConversationHeader.tsx +13 -1
- package/src/client/conversations/ConversationMemories.tsx +10 -5
- package/src/client/conversations/ConversationPage.tsx +35 -3
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +30 -23
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -11
- package/src/client/conversations/queries.ts +68 -3
- package/src/client/documentTitle.ts +28 -0
- package/src/client/format.ts +15 -0
- package/src/client/pages/PersonalTokensPage.tsx +2 -0
- package/src/client/pages/SettingsPage.tsx +2 -0
- package/src/client/pages/automations/AutomationEditPage.tsx +8 -0
- package/src/client/pages/automations/AutomationExecutionsPage.tsx +1 -0
- package/src/client/pages/automations/AutomationVersionsPage.tsx +1 -0
- package/src/client/pages/automations/AutomationsPage.tsx +7 -0
- package/src/client/pages/code/RepositoryPage.tsx +5 -0
- package/src/client/pages/dev/SegmentedFixtures.tsx +1 -1
- package/src/mock-reporting/fixtures.ts +68 -2
|
@@ -19,11 +19,12 @@ export function segmentedTabClass(selected: boolean): string {
|
|
|
19
19
|
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/** Switch between related panels
|
|
22
|
+
/** Switch between related panels. Tabs share the row equally on wide screens. */
|
|
23
23
|
export function SegmentedTabs<const Value extends string>(props: {
|
|
24
24
|
children: ReactNode;
|
|
25
25
|
label: string;
|
|
26
|
-
|
|
26
|
+
/** `count` is the number of entries in the panel. Zero and unknown counts are hidden. */
|
|
27
|
+
items: readonly { count?: number; label: string; value: Value }[];
|
|
27
28
|
onChange(value: NoInfer<Value>): void;
|
|
28
29
|
value: NoInfer<Value>;
|
|
29
30
|
}) {
|
|
@@ -33,11 +34,7 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
33
34
|
<div className="grid min-w-0 gap-4">
|
|
34
35
|
<div
|
|
35
36
|
aria-label={props.label}
|
|
36
|
-
className={cn(
|
|
37
|
-
"grid auto-cols-fr grid-flow-col",
|
|
38
|
-
segmentedTabsFrameClass,
|
|
39
|
-
segmentedTabsInsetClass,
|
|
40
|
-
)}
|
|
37
|
+
className={cn("flex", segmentedTabsFrameClass, segmentedTabsInsetClass)}
|
|
41
38
|
role="tablist"
|
|
42
39
|
>
|
|
43
40
|
{props.items.map((item, index) => (
|
|
@@ -45,7 +42,7 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
45
42
|
aria-controls={`${id}-panel`}
|
|
46
43
|
aria-selected={props.value === item.value}
|
|
47
44
|
className={cn(
|
|
48
|
-
"min-w-0",
|
|
45
|
+
"inline-flex min-w-0 flex-auto items-center justify-center gap-1.5 sm:flex-1",
|
|
49
46
|
segmentedTabClass(props.value === item.value),
|
|
50
47
|
)}
|
|
51
48
|
id={`${id}-${item.value}`}
|
|
@@ -80,7 +77,12 @@ export function SegmentedTabs<const Value extends string>(props: {
|
|
|
80
77
|
tabIndex={props.value === item.value ? 0 : -1}
|
|
81
78
|
type="button"
|
|
82
79
|
>
|
|
83
|
-
{item.label}
|
|
80
|
+
<span className="truncate">{item.label}</span>
|
|
81
|
+
{item.count ? (
|
|
82
|
+
<span className="min-w-5 shrink-0 rounded-full bg-dashboard-fill-strong px-1.5 text-center text-xs font-semibold leading-5 tabular-nums">
|
|
83
|
+
{item.count.toLocaleString("en-US")}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
84
86
|
</button>
|
|
85
87
|
))}
|
|
86
88
|
</div>
|
|
@@ -4,16 +4,27 @@ import {
|
|
|
4
4
|
TimeRangeSelector,
|
|
5
5
|
type TimeRangeDays,
|
|
6
6
|
} from "../controls/TimeRangeSelector";
|
|
7
|
+
import { useDocumentTitle } from "../../documentTitle";
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Render a spacious analytics page heading with optional controls.
|
|
11
|
+
*
|
|
12
|
+
* Also names the browser tab after the page. Pass `documentTitle` when the tab
|
|
13
|
+
* needs more context than the visible heading.
|
|
14
|
+
*/
|
|
9
15
|
export function PageHeader(props: {
|
|
10
16
|
actions?: ReactNode;
|
|
11
17
|
description: ReactNode;
|
|
18
|
+
documentTitle?: string;
|
|
12
19
|
onRangeChange?(value: TimeRangeDays): void;
|
|
13
20
|
range?: TimeRangeDays;
|
|
14
21
|
rangeOptions?: TimeRangeDays[];
|
|
15
22
|
title: ReactNode;
|
|
16
23
|
}) {
|
|
24
|
+
useDocumentTitle(
|
|
25
|
+
props.documentTitle ??
|
|
26
|
+
(typeof props.title === "string" ? props.title : undefined),
|
|
27
|
+
);
|
|
17
28
|
const rangeActions =
|
|
18
29
|
props.range !== undefined && props.onRangeChange ? (
|
|
19
30
|
<TimeRangeSelector
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Bot, ChevronRight } from "lucide-react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { ConversationAgentTreeNode } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { conversationPath } from "./conversationRoutes";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Show the parents of a child Conversation, root first. The page title already
|
|
9
|
+
* names this agent, so the last node is not shown. Root Conversations have no
|
|
10
|
+
* tree.
|
|
11
|
+
*/
|
|
12
|
+
export function ConversationAgentTree(props: {
|
|
13
|
+
nodes: readonly ConversationAgentTreeNode[];
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<nav
|
|
17
|
+
aria-label="Agent tree"
|
|
18
|
+
className="flex min-w-0 items-center gap-1.5 text-xs leading-snug text-dashboard-text-muted"
|
|
19
|
+
>
|
|
20
|
+
<Bot aria-hidden="true" className="shrink-0 text-cyan-300" size={12} />
|
|
21
|
+
<ol className="m-0 flex min-w-0 list-none items-center gap-1 p-0">
|
|
22
|
+
{props.nodes.slice(0, -1).map((node, index) => (
|
|
23
|
+
<li
|
|
24
|
+
className="inline-flex min-w-0 items-center gap-1"
|
|
25
|
+
key={node.conversationId}
|
|
26
|
+
>
|
|
27
|
+
{index > 0 ? (
|
|
28
|
+
<ChevronRight aria-hidden="true" className="shrink-0" size={12} />
|
|
29
|
+
) : null}
|
|
30
|
+
<Link
|
|
31
|
+
className="min-w-0 max-w-[16rem] truncate font-medium text-dashboard-text no-underline underline-offset-2 hover:underline"
|
|
32
|
+
title={node.label}
|
|
33
|
+
to={conversationPath(node.conversationId)}
|
|
34
|
+
>
|
|
35
|
+
{node.label}
|
|
36
|
+
</Link>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ol>
|
|
40
|
+
</nav>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -1,42 +1,65 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
|
-
import { Bot } from "lucide-react";
|
|
3
|
-
import { Link } from "react-router";
|
|
2
|
+
import { Bot, CircleAlert, LockKeyhole } from "lucide-react";
|
|
4
3
|
import {
|
|
5
4
|
conversationChildAgentsReportSchema,
|
|
6
5
|
type ConversationChildAgent,
|
|
7
6
|
} from "@sentry/junior/api/schema";
|
|
8
7
|
|
|
9
8
|
import { StatusChip, type StatusChipTone } from "../components/StatusChip";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
formatConversationCostTotal,
|
|
11
|
+
formatRelativeTime,
|
|
12
|
+
formatRuntime,
|
|
13
|
+
formatTime,
|
|
14
|
+
formatUsageTotal,
|
|
15
|
+
} from "../format";
|
|
11
16
|
import { fetchDashboardJson } from "../http";
|
|
12
|
-
import {
|
|
17
|
+
import { cn } from "../styles";
|
|
18
|
+
import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
|
|
13
19
|
|
|
14
|
-
const
|
|
20
|
+
const statusTones: Partial<Record<string, StatusChipTone>> = {
|
|
21
|
+
active: "info",
|
|
15
22
|
blocked: "warning",
|
|
16
23
|
completed: "success",
|
|
24
|
+
error: "danger",
|
|
17
25
|
failed: "danger",
|
|
18
26
|
paused: "info",
|
|
19
|
-
pending: "neutral",
|
|
20
27
|
running: "info",
|
|
21
|
-
stopped: "neutral",
|
|
22
28
|
};
|
|
23
29
|
|
|
24
|
-
/**
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
/** Pick the status tone for a child agent, its transcript row, or its detail. */
|
|
31
|
+
export function subagentStatusTone(status: string): StatusChipTone {
|
|
32
|
+
return statusTones[status] ?? "neutral";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const errorToneClass: Partial<Record<StatusChipTone, string>> = {
|
|
36
|
+
danger: "text-rose-200/85",
|
|
37
|
+
warning: "text-amber-200/85",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Load the child agents of one Conversation with runtime status. */
|
|
41
|
+
export function useConversationChildAgents(conversationId: string) {
|
|
42
|
+
return useQuery({
|
|
31
43
|
queryFn: ({ signal }) =>
|
|
32
44
|
fetchDashboardJson(
|
|
33
45
|
conversationChildAgentsReportSchema,
|
|
34
|
-
`/api/conversations/${encodeURIComponent(
|
|
46
|
+
`/api/conversations/${encodeURIComponent(conversationId)}/agents`,
|
|
35
47
|
signal,
|
|
36
48
|
),
|
|
37
|
-
queryKey: ["conversation",
|
|
49
|
+
queryKey: ["conversation", conversationId, "agents"],
|
|
38
50
|
retry: false,
|
|
39
51
|
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Show the child agents of one Conversation with runtime status. A row opens
|
|
56
|
+
* that child's transcript.
|
|
57
|
+
*/
|
|
58
|
+
export function ConversationChildAgents(props: {
|
|
59
|
+
conversationId: string;
|
|
60
|
+
onOpenAgent(target: SubagentTranscriptTarget): void;
|
|
61
|
+
}) {
|
|
62
|
+
const query = useConversationChildAgents(props.conversationId);
|
|
40
63
|
|
|
41
64
|
if (query.isPending) {
|
|
42
65
|
return (
|
|
@@ -62,51 +85,104 @@ export function ConversationChildAgents(props: {
|
|
|
62
85
|
}
|
|
63
86
|
|
|
64
87
|
return (
|
|
65
|
-
<ol aria-label="Subagents" className="m-0 grid list-none gap-
|
|
88
|
+
<ol aria-label="Subagents" className="m-0 grid list-none gap-2 p-0">
|
|
66
89
|
{query.data.agents.map((agent) => (
|
|
67
|
-
<li
|
|
68
|
-
|
|
69
|
-
key={agent.conversationId}
|
|
70
|
-
>
|
|
71
|
-
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
72
|
-
<Link
|
|
73
|
-
className="min-w-0 truncate font-mono text-xs font-semibold text-dashboard-text no-underline hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-dashboard-focus"
|
|
74
|
-
onClick={props.onNavigate}
|
|
75
|
-
to={conversationPath(agent.conversationId)}
|
|
76
|
-
>
|
|
77
|
-
{agent.redacted ? "Private subagent" : (agent.name ?? "agent")}
|
|
78
|
-
</Link>
|
|
79
|
-
<StatusChip
|
|
80
|
-
className="ml-auto"
|
|
81
|
-
size="compact"
|
|
82
|
-
tone={statusTone[agent.status]}
|
|
83
|
-
>
|
|
84
|
-
{agent.status}
|
|
85
|
-
</StatusChip>
|
|
86
|
-
</div>
|
|
87
|
-
{agent.title ? (
|
|
88
|
-
<p className="m-0 break-words text-sm font-semibold text-dashboard-text">
|
|
89
|
-
{agent.title}
|
|
90
|
-
</p>
|
|
91
|
-
) : null}
|
|
92
|
-
{agent.task ? (
|
|
93
|
-
<p className="m-0 line-clamp-4 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
|
|
94
|
-
{agent.task}
|
|
95
|
-
</p>
|
|
96
|
-
) : null}
|
|
97
|
-
{agent.error ? (
|
|
98
|
-
<p className="m-0 whitespace-pre-wrap break-words text-xs leading-relaxed text-dashboard-text-muted">
|
|
99
|
-
{agent.error}
|
|
100
|
-
</p>
|
|
101
|
-
) : null}
|
|
102
|
-
<time
|
|
103
|
-
className="font-mono text-xs text-dashboard-text-muted"
|
|
104
|
-
dateTime={agent.updatedAt}
|
|
105
|
-
>
|
|
106
|
-
{formatMessageTimestamp(Date.parse(agent.updatedAt), true)}
|
|
107
|
-
</time>
|
|
90
|
+
<li key={agent.conversationId}>
|
|
91
|
+
<ChildAgentRow agent={agent} onOpen={props.onOpenAgent} />
|
|
108
92
|
</li>
|
|
109
93
|
))}
|
|
110
94
|
</ol>
|
|
111
95
|
);
|
|
112
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Show who ran, what it was asked to do, and what it cost. The row text is the
|
|
100
|
+
* accessible name, so status, title, task, and error stay audible.
|
|
101
|
+
*/
|
|
102
|
+
function ChildAgentRow(props: {
|
|
103
|
+
agent: ConversationChildAgent;
|
|
104
|
+
onOpen(target: SubagentTranscriptTarget): void;
|
|
105
|
+
}) {
|
|
106
|
+
const { agent } = props;
|
|
107
|
+
const tone = subagentStatusTone(agent.status);
|
|
108
|
+
const task = agent.task?.trim();
|
|
109
|
+
const headline = agent.title ?? task;
|
|
110
|
+
// A title is often a short form of the task. Skip the task when it adds nothing.
|
|
111
|
+
const detail = agent.title && task && task !== agent.title ? task : undefined;
|
|
112
|
+
const runtime = formatRuntime(agent.cumulativeDurationMs);
|
|
113
|
+
const stats = [
|
|
114
|
+
formatUsageTotal(agent.cumulativeUsage),
|
|
115
|
+
formatConversationCostTotal(agent.cumulativeUsage, agent.auxiliaryCosts),
|
|
116
|
+
runtime ? `${runtime} runtime` : "",
|
|
117
|
+
].filter(Boolean);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<button
|
|
121
|
+
className="grid w-full min-w-0 cursor-pointer gap-1.5 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface px-3 py-2.5 text-left text-inherit transition-colors duration-200 hover:border-dashboard-border-emphasis hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
122
|
+
onClick={() =>
|
|
123
|
+
props.onOpen({
|
|
124
|
+
conversationId: agent.conversationId,
|
|
125
|
+
...(agent.redacted
|
|
126
|
+
? undefined
|
|
127
|
+
: { label: agent.title ?? agent.name }),
|
|
128
|
+
status: agent.status,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
type="button"
|
|
132
|
+
>
|
|
133
|
+
<span className="flex min-w-0 items-center gap-2 text-xs">
|
|
134
|
+
{agent.redacted ? (
|
|
135
|
+
<span className="flex min-w-0 items-center gap-1.5 text-dashboard-text-muted">
|
|
136
|
+
<LockKeyhole aria-hidden="true" className="size-3 shrink-0" />
|
|
137
|
+
Private subagent
|
|
138
|
+
</span>
|
|
139
|
+
) : (
|
|
140
|
+
<span className="min-w-0 truncate font-mono text-dashboard-text-muted">
|
|
141
|
+
{agent.name ?? "subagent"}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
<StatusChip className="ml-auto shrink-0" size="compact" tone={tone}>
|
|
145
|
+
{agent.status}
|
|
146
|
+
</StatusChip>
|
|
147
|
+
<time
|
|
148
|
+
className="shrink-0 text-dashboard-text-faint"
|
|
149
|
+
dateTime={agent.updatedAt}
|
|
150
|
+
title={`Updated ${formatTime(agent.updatedAt, {
|
|
151
|
+
dateStyle: "medium",
|
|
152
|
+
timeStyle: "short",
|
|
153
|
+
})}`}
|
|
154
|
+
>
|
|
155
|
+
{formatRelativeTime(agent.updatedAt)}
|
|
156
|
+
</time>
|
|
157
|
+
</span>
|
|
158
|
+
{headline ? (
|
|
159
|
+
<span className="line-clamp-2 break-words text-sm font-semibold leading-snug text-dashboard-text">
|
|
160
|
+
{headline}
|
|
161
|
+
</span>
|
|
162
|
+
) : null}
|
|
163
|
+
{detail ? (
|
|
164
|
+
<span className="line-clamp-2 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text-muted">
|
|
165
|
+
{detail}
|
|
166
|
+
</span>
|
|
167
|
+
) : null}
|
|
168
|
+
{agent.error ? (
|
|
169
|
+
<span
|
|
170
|
+
className={cn(
|
|
171
|
+
"flex min-w-0 items-start gap-1.5 text-xs leading-relaxed",
|
|
172
|
+
errorToneClass[tone] ?? "text-dashboard-text-muted",
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
<CircleAlert aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
|
|
176
|
+
<span className="line-clamp-3 min-w-0 whitespace-pre-wrap break-words">
|
|
177
|
+
{agent.error}
|
|
178
|
+
</span>
|
|
179
|
+
</span>
|
|
180
|
+
) : null}
|
|
181
|
+
{stats.length > 0 ? (
|
|
182
|
+
<span className="block font-mono text-xs tabular-nums text-dashboard-text-muted">
|
|
183
|
+
{stats.join(" · ")}
|
|
184
|
+
</span>
|
|
185
|
+
) : null}
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type KeyboardEvent,
|
|
11
11
|
type ReactNode,
|
|
12
12
|
} from "react";
|
|
13
|
-
import { ImagePlus, Send } from "lucide-react";
|
|
13
|
+
import { ImagePlus, Send, Square } from "lucide-react";
|
|
14
14
|
|
|
15
15
|
import { Button } from "../components/Button";
|
|
16
16
|
import { useDashboardOnline } from "../connection";
|
|
@@ -78,6 +78,12 @@ type ConversationComposerProps = {
|
|
|
78
78
|
images?: InputImage[],
|
|
79
79
|
): Promise<void>;
|
|
80
80
|
onSubmitStart?: () => void;
|
|
81
|
+
/**
|
|
82
|
+
* Stop the active Turn. Present only while a Turn can be stopped. Resolves
|
|
83
|
+
* with queued text that the stop removed, which returns to the draft.
|
|
84
|
+
*/
|
|
85
|
+
onStop?: () => Promise<string | undefined>;
|
|
86
|
+
stopPending?: boolean;
|
|
81
87
|
};
|
|
82
88
|
|
|
83
89
|
/** Render the dashboard message composer for a new or existing conversation. */
|
|
@@ -115,6 +121,7 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
115
121
|
});
|
|
116
122
|
// Blocks same-tick double fire. For create restore, also blocks until settle.
|
|
117
123
|
const submittingRef = useRef(false);
|
|
124
|
+
const stoppingRef = useRef(false);
|
|
118
125
|
// Monotonic token so a late failed create never restores over a newer submit.
|
|
119
126
|
const submitTokenRef = useRef(0);
|
|
120
127
|
const sendLocked =
|
|
@@ -304,6 +311,30 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
304
311
|
}
|
|
305
312
|
};
|
|
306
313
|
|
|
314
|
+
// Put queued text back ahead of anything the reader already typed.
|
|
315
|
+
const stopTurn = async () => {
|
|
316
|
+
if (!props.onStop || stoppingRef.current) return;
|
|
317
|
+
stoppingRef.current = true;
|
|
318
|
+
let restored: string | undefined;
|
|
319
|
+
try {
|
|
320
|
+
restored = await props.onStop();
|
|
321
|
+
} catch {
|
|
322
|
+
// The parent owns the stop error message.
|
|
323
|
+
return;
|
|
324
|
+
} finally {
|
|
325
|
+
stoppingRef.current = false;
|
|
326
|
+
}
|
|
327
|
+
if (!restored) return;
|
|
328
|
+
const current = draftRef.current.text;
|
|
329
|
+
const next: ConversationDraft = {
|
|
330
|
+
...draftRef.current,
|
|
331
|
+
text: current.trim() ? `${restored}\n${current}` : restored,
|
|
332
|
+
};
|
|
333
|
+
replaceMessage(next);
|
|
334
|
+
storeDraft(storageKey, next);
|
|
335
|
+
textareaRef.current?.focus({ preventScroll: true });
|
|
336
|
+
};
|
|
337
|
+
|
|
307
338
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
308
339
|
if (
|
|
309
340
|
event.key === "Enter" &&
|
|
@@ -317,20 +348,12 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
317
348
|
|
|
318
349
|
return (
|
|
319
350
|
<div className="grid min-w-0 gap-1.5">
|
|
320
|
-
{
|
|
351
|
+
{props.error || imageError ? (
|
|
321
352
|
<div
|
|
322
353
|
aria-live="polite"
|
|
323
|
-
className=
|
|
324
|
-
!online
|
|
325
|
-
? "min-w-0 font-mono text-xs leading-relaxed text-amber-100/80"
|
|
326
|
-
: "min-w-0 font-mono text-xs leading-relaxed text-red-300/80"
|
|
327
|
-
}
|
|
354
|
+
className="min-w-0 font-mono text-xs leading-relaxed text-red-300/80"
|
|
328
355
|
>
|
|
329
|
-
{
|
|
330
|
-
? images.length
|
|
331
|
-
? "Connect to send. Image drafts stay here until you leave."
|
|
332
|
-
: "Connect to send. Your draft is saved."
|
|
333
|
-
: (imageError ?? props.error)}
|
|
356
|
+
{imageError ?? props.error}
|
|
334
357
|
</div>
|
|
335
358
|
) : null}
|
|
336
359
|
<form
|
|
@@ -451,29 +474,39 @@ export const ConversationComposer = memo(function ConversationComposer(
|
|
|
451
474
|
</div>
|
|
452
475
|
)}
|
|
453
476
|
</div>
|
|
454
|
-
<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
478
|
+
{props.onStop ? (
|
|
479
|
+
<Button
|
|
480
|
+
aria-label={props.stopPending ? "Stopping" : "Stop"}
|
|
481
|
+
className="rounded-lg font-sans"
|
|
482
|
+
disabled={props.stopPending || !online}
|
|
483
|
+
onClick={() => void stopTurn()}
|
|
484
|
+
title="Stop Junior"
|
|
485
|
+
>
|
|
486
|
+
<Square aria-hidden="true" fill="currentColor" size={12} />
|
|
487
|
+
<span className="hidden md:inline">
|
|
488
|
+
{props.stopPending ? "Stopping…" : "Stop"}
|
|
489
|
+
</span>
|
|
490
|
+
</Button>
|
|
491
|
+
) : null}
|
|
492
|
+
<Button
|
|
493
|
+
aria-label={sendLocked ? "Sending message" : props.submitLabel}
|
|
494
|
+
className="rounded-lg !border-0 !bg-cyan-100 font-sans !text-dashboard-text-inverse hover:!bg-cyan-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-cyan-300/55 disabled:!bg-dashboard-fill-hover disabled:!text-dashboard-text-muted"
|
|
495
|
+
disabled={
|
|
496
|
+
(!canSend && !images.length) ||
|
|
497
|
+
!online ||
|
|
498
|
+
sendLocked ||
|
|
499
|
+
readingImages
|
|
500
|
+
}
|
|
501
|
+
title={sendLocked ? "Sending message" : props.submitLabel}
|
|
502
|
+
type="submit"
|
|
503
|
+
>
|
|
504
|
+
<Send aria-hidden="true" size={14} />
|
|
505
|
+
<span className="hidden md:inline">
|
|
506
|
+
{sendLocked ? "Sending…" : props.submitLabel}
|
|
507
|
+
</span>
|
|
508
|
+
</Button>
|
|
509
|
+
</div>
|
|
477
510
|
</div>
|
|
478
511
|
</form>
|
|
479
512
|
</div>
|
|
@@ -5,15 +5,18 @@ import { formatRelativeTime, formatTime } from "../format";
|
|
|
5
5
|
import { ButtonLink } from "../components/Button";
|
|
6
6
|
import { Drawer } from "../components/Drawer";
|
|
7
7
|
import { SegmentedTabs } from "../components/SegmentedTabs";
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
ConversationChildAgents,
|
|
10
|
+
useConversationChildAgents,
|
|
11
|
+
} from "./ConversationChildAgents";
|
|
12
|
+
import {
|
|
13
|
+
ConversationMemories,
|
|
14
|
+
useConversationMemories,
|
|
15
|
+
} from "./ConversationMemories";
|
|
10
16
|
import { ShareConversationButton } from "./ShareConversationButton";
|
|
17
|
+
import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
{ label: "Details", value: "details" },
|
|
14
|
-
{ label: "Memories", value: "memories" },
|
|
15
|
-
{ label: "Subagents", value: "agents" },
|
|
16
|
-
] as const;
|
|
19
|
+
type DetailsTab = "agents" | "details" | "memories";
|
|
17
20
|
|
|
18
21
|
/** Put conversation context before usage and diagnostic links. */
|
|
19
22
|
export function ConversationDetailsDrawer(props: {
|
|
@@ -23,12 +26,26 @@ export function ConversationDetailsDrawer(props: {
|
|
|
23
26
|
identity: ReactNode;
|
|
24
27
|
lastActivityAt?: string;
|
|
25
28
|
onClose(): void;
|
|
29
|
+
/** Open one child agent transcript. The details drawer closes first. */
|
|
30
|
+
onOpenSubagent(target: SubagentTranscriptTarget): void;
|
|
26
31
|
sentryConversationUrl?: string;
|
|
27
32
|
privacy: ReactNode;
|
|
28
33
|
stats: ReactNode;
|
|
29
34
|
title: string;
|
|
30
35
|
}) {
|
|
31
|
-
const [tab, setTab] = useState<
|
|
36
|
+
const [tab, setTab] = useState<DetailsTab>("details");
|
|
37
|
+
// Load both lists with the drawer so each tab can show its count.
|
|
38
|
+
const memories = useConversationMemories(props.conversationId);
|
|
39
|
+
const agents = useConversationChildAgents(props.conversationId);
|
|
40
|
+
const tabs = [
|
|
41
|
+
{ label: "Details", value: "details" },
|
|
42
|
+
{
|
|
43
|
+
count: memories.data?.memories.length,
|
|
44
|
+
label: "Memories",
|
|
45
|
+
value: "memories",
|
|
46
|
+
},
|
|
47
|
+
{ count: agents.data?.agents.length, label: "Subagents", value: "agents" },
|
|
48
|
+
] as const;
|
|
32
49
|
const titleId = "conversation-details-drawer-title";
|
|
33
50
|
const sections = [
|
|
34
51
|
{ content: props.brief, title: "Summary" },
|
|
@@ -70,7 +87,10 @@ export function ConversationDetailsDrawer(props: {
|
|
|
70
87
|
) : tab === "agents" ? (
|
|
71
88
|
<ConversationChildAgents
|
|
72
89
|
conversationId={props.conversationId}
|
|
73
|
-
|
|
90
|
+
onOpenAgent={(target) => {
|
|
91
|
+
props.onClose();
|
|
92
|
+
props.onOpenSubagent(target);
|
|
93
|
+
}}
|
|
74
94
|
/>
|
|
75
95
|
) : sections.length > 0 ? (
|
|
76
96
|
<div className="grid min-w-0 gap-5">
|
|
@@ -24,9 +24,12 @@ import {
|
|
|
24
24
|
} from "./ConversationHeaderActions";
|
|
25
25
|
import { ConversationDetailsDrawer } from "./ConversationDetailsDrawer";
|
|
26
26
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
27
|
+
import type { SubagentTranscriptTarget } from "./SubagentTranscriptDrawer";
|
|
27
28
|
|
|
28
29
|
/** Show the conversation title, search, and details. */
|
|
29
30
|
export function ConversationHeader(props: {
|
|
31
|
+
/** Parent chain of a child Conversation. Root Conversations omit it. */
|
|
32
|
+
agentTree?: ReactNode;
|
|
30
33
|
copyAction?: ReactNode;
|
|
31
34
|
annotations: ReactNode;
|
|
32
35
|
archive: ConversationArchiveAction;
|
|
@@ -40,6 +43,7 @@ export function ConversationHeader(props: {
|
|
|
40
43
|
sentryConversationUrl?: string;
|
|
41
44
|
live: boolean;
|
|
42
45
|
meta?: ReactNode;
|
|
46
|
+
onOpenSubagent(target: SubagentTranscriptTarget): void;
|
|
43
47
|
onSearchChange(value: string): void;
|
|
44
48
|
onViewChange(value: TranscriptViewMode): void;
|
|
45
49
|
privacy: ReactNode;
|
|
@@ -154,7 +158,8 @@ export function ConversationHeader(props: {
|
|
|
154
158
|
searchOpenVisible ||
|
|
155
159
|
props.archive.error ||
|
|
156
160
|
Boolean(props.linkedWork) ||
|
|
157
|
-
Boolean(props.forks)
|
|
161
|
+
Boolean(props.forks) ||
|
|
162
|
+
Boolean(props.agentTree);
|
|
158
163
|
|
|
159
164
|
const liveIndicator = props.live ? (
|
|
160
165
|
<span
|
|
@@ -176,6 +181,9 @@ export function ConversationHeader(props: {
|
|
|
176
181
|
>
|
|
177
182
|
<div className="hidden min-w-0 items-start justify-between gap-4 px-7 py-5 md:flex">
|
|
178
183
|
<div className="min-w-0">
|
|
184
|
+
{props.agentTree ? (
|
|
185
|
+
<div className="mb-1.5 min-w-0">{props.agentTree}</div>
|
|
186
|
+
) : null}
|
|
179
187
|
<div className="flex min-w-0 items-center gap-2">
|
|
180
188
|
<h2 className="m-0 line-clamp-1 min-w-0 font-display text-xl font-medium leading-tight tracking-tight">
|
|
181
189
|
{props.title}
|
|
@@ -204,6 +212,9 @@ export function ConversationHeader(props: {
|
|
|
204
212
|
view={props.view}
|
|
205
213
|
/>
|
|
206
214
|
</div>
|
|
215
|
+
{props.agentTree ? (
|
|
216
|
+
<div className="px-4 py-2 md:hidden">{props.agentTree}</div>
|
|
217
|
+
) : null}
|
|
207
218
|
{props.forks ? (
|
|
208
219
|
<div className="px-4 py-2 md:hidden">{props.forks}</div>
|
|
209
220
|
) : null}
|
|
@@ -251,6 +262,7 @@ export function ConversationHeader(props: {
|
|
|
251
262
|
lastActivityAt={props.lastActivityAt}
|
|
252
263
|
sentryConversationUrl={props.sentryConversationUrl}
|
|
253
264
|
onClose={() => setDetailsOpen(false)}
|
|
265
|
+
onOpenSubagent={props.onOpenSubagent}
|
|
254
266
|
privacy={props.privacy}
|
|
255
267
|
stats={props.stats}
|
|
256
268
|
title={props.title}
|
|
@@ -21,18 +21,23 @@ const conversationMemoryListSchema = z
|
|
|
21
21
|
})
|
|
22
22
|
.strict();
|
|
23
23
|
|
|
24
|
-
/** Load
|
|
25
|
-
export function
|
|
26
|
-
|
|
24
|
+
/** Load the memories captured from one Conversation. */
|
|
25
|
+
export function useConversationMemories(conversationId: string) {
|
|
26
|
+
return useQuery({
|
|
27
27
|
queryFn: ({ signal }) =>
|
|
28
28
|
fetchDashboardJson(
|
|
29
29
|
conversationMemoryListSchema,
|
|
30
|
-
`/api/plugins/memory/conversations/${encodeURIComponent(
|
|
30
|
+
`/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
|
|
31
31
|
signal,
|
|
32
32
|
),
|
|
33
|
-
queryKey: ["conversation",
|
|
33
|
+
queryKey: ["conversation", conversationId, "memories"],
|
|
34
34
|
retry: false,
|
|
35
35
|
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Show the memories captured from one Conversation. */
|
|
39
|
+
export function ConversationMemories(props: { conversationId: string }) {
|
|
40
|
+
const query = useConversationMemories(props.conversationId);
|
|
36
41
|
|
|
37
42
|
if (query.isPending) {
|
|
38
43
|
return (
|