@sentry/junior-dashboard 0.274.0 → 0.276.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/app.js +28 -9
  2. package/dist/client.js +115 -92
  3. package/dist/index.js +28 -9
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +5 -4
  6. package/src/app.ts +8 -3
  7. package/src/client/App.tsx +11 -0
  8. package/src/client/components/Metric.tsx +0 -32
  9. package/src/client/components/ProfileMenu.tsx +56 -4
  10. package/src/client/components/layout/DashboardChrome.tsx +3 -3
  11. package/src/client/components/layout/DashboardHeader.tsx +41 -4
  12. package/src/client/components/layout/NavRail.tsx +43 -40
  13. package/src/client/conversations/ConversationComposer.tsx +42 -26
  14. package/src/client/conversations/ConversationDetails.tsx +149 -166
  15. package/src/client/conversations/ConversationHeader.tsx +126 -132
  16. package/src/client/conversations/ConversationHeaderActions.tsx +92 -120
  17. package/src/client/conversations/ConversationListStatusIcon.tsx +16 -11
  18. package/src/client/conversations/ConversationMemories.tsx +118 -78
  19. package/src/client/conversations/ConversationMeta.tsx +94 -84
  20. package/src/client/conversations/ConversationOverflowMenu.tsx +59 -0
  21. package/src/client/conversations/ConversationPage.tsx +197 -422
  22. package/src/client/conversations/ConversationReplyFooter.tsx +342 -0
  23. package/src/client/conversations/ConversationSidebar.tsx +5 -7
  24. package/src/client/conversations/ConversationTranscript.tsx +37 -8
  25. package/src/client/conversations/ConversationWatches.tsx +37 -15
  26. package/src/client/conversations/PendingMailboxStack.tsx +11 -1
  27. package/src/client/conversations/QuickQuestion.tsx +476 -0
  28. package/src/client/conversations/TelemetryMetrics.tsx +2 -15
  29. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  30. package/src/client/conversations/TranscriptEventView.tsx +25 -2
  31. package/src/client/conversations/TranscriptMemoryPopover.tsx +179 -0
  32. package/src/client/conversations/TranscriptMessageView.tsx +46 -16
  33. package/src/client/conversations/TranscriptRows.tsx +9 -2
  34. package/src/client/conversations/TranscriptView.tsx +15 -32
  35. package/src/client/conversations/UsageCard.tsx +4 -18
  36. package/src/client/conversations/conversationMemory.ts +138 -0
  37. package/src/client/conversations/conversationSections.ts +18 -14
  38. package/src/client/conversations/eventTranscript.ts +8 -0
  39. package/src/client/review-desk/ConversationReviewItems.tsx +99 -0
  40. package/src/client/review-desk/ReviewItemActions.tsx +1 -1
  41. package/src/client/review-desk/ReviewItemChangeRow.tsx +85 -0
  42. package/src/client/review-desk/ReviewItemContext.tsx +41 -23
  43. package/src/client/review-desk/reviewItemFormat.ts +16 -0
  44. package/src/client/sentry.ts +77 -0
  45. package/src/client/types.ts +6 -0
  46. package/src/client.tsx +3 -0
  47. package/src/mock-reporting/watches.ts +2 -0
  48. package/src/shell.ts +27 -2
  49. package/src/tailwind.css +12 -19
  50. package/src/client/components/ActiveIndicator.tsx +0 -14
  51. package/src/client/conversations/ConversationHeaderMeta.tsx +0 -20
  52. package/src/client/review-desk/ConversationReviewBar.tsx +0 -85
@@ -0,0 +1,179 @@
1
+ import { Brain } from "lucide-react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import { Link } from "react-router";
4
+
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from "../components/ui/popover";
10
+ import { formatMessageTimestamp, memoryPath } from "../format";
11
+ import { cn } from "../styles";
12
+ import type { TranscriptViewMessage } from "../types";
13
+ import {
14
+ anchorMemories,
15
+ memoryAnchorKey,
16
+ useConversationMemoryState,
17
+ type ConversationMemory,
18
+ type ConversationMemoryState,
19
+ type MemoryAnchor,
20
+ } from "./conversationMemory";
21
+ import { memoryRecallContent } from "./turnContext";
22
+
23
+ function memoryCount(count: number): string {
24
+ return `${count} ${count === 1 ? "memory" : "memories"}`;
25
+ }
26
+
27
+ /**
28
+ * Open one transcript spot's memories in a popover, so the transcript keeps
29
+ * its length. The page owns which popover is open, so the details panel can
30
+ * open one too.
31
+ */
32
+ function MemoryPopover(props: {
33
+ anchor: MemoryAnchor;
34
+ label: string;
35
+ memories: ConversationMemory[];
36
+ state: ConversationMemoryState;
37
+ trigger: ReactElement;
38
+ }) {
39
+ const { anchor, state } = props;
40
+ const open =
41
+ state.open?.source === anchor.source && state.open.turnId === anchor.turnId;
42
+ const highlightId = open ? state.open?.memoryId : undefined;
43
+ return (
44
+ <Popover
45
+ onOpenChange={(next) => state.setOpen(next ? anchor : undefined)}
46
+ open={open}
47
+ >
48
+ <PopoverTrigger
49
+ data-memory-anchor={memoryAnchorKey(anchor)}
50
+ render={props.trigger}
51
+ />
52
+ <PopoverContent
53
+ align="start"
54
+ aria-label={props.label}
55
+ className="max-h-[min(28rem,var(--available-height))] w-96 p-1.5"
56
+ >
57
+ <ul className="m-0 grid list-none gap-0.5 p-0">
58
+ {props.memories.map((memory) => (
59
+ <li
60
+ className={cn(
61
+ "grid gap-1.5 rounded-md px-2.5 py-2",
62
+ memory.id === highlightId && "bg-dashboard-fill-strong",
63
+ )}
64
+ key={memory.id}
65
+ ref={
66
+ memory.id === highlightId
67
+ ? (node) => node?.scrollIntoView({ block: "nearest" })
68
+ : undefined
69
+ }
70
+ >
71
+ <p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed text-dashboard-text">
72
+ {memory.content}
73
+ </p>
74
+ <div className="flex items-center gap-3 text-xs text-dashboard-text-muted">
75
+ {memory.visibility === "private" ? <span>Private</span> : null}
76
+ <Link
77
+ className="text-dashboard-text-muted no-underline hover:text-dashboard-text hover:underline"
78
+ to={memoryPath(memory.id)}
79
+ >
80
+ Open
81
+ </Link>
82
+ </div>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </PopoverContent>
87
+ </Popover>
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Show the memories that a prompt recalled, under that prompt. One prompt can
93
+ * start more than one Turn, and each Turn recalls, so each gets a chip.
94
+ */
95
+ export function TranscriptRecalledMemories(props: {
96
+ className?: string;
97
+ message: TranscriptViewMessage;
98
+ }) {
99
+ const state = useConversationMemoryState();
100
+ const anchors = [
101
+ ...new Set(
102
+ props.message.contexts
103
+ ?.filter((context) => memoryRecallContent(context))
104
+ .map((context) => context.turnId),
105
+ ),
106
+ ].flatMap((turnId) => {
107
+ const anchor = { source: "recalled", turnId } as const;
108
+ const memories = anchorMemories(state?.list, anchor);
109
+ return memories.length > 0 ? [{ anchor, memories }] : [];
110
+ });
111
+ if (!state || anchors.length === 0) return null;
112
+ return (
113
+ <div className={cn("flex flex-wrap gap-2", props.className)}>
114
+ {anchors.map(({ anchor, memories }) => {
115
+ const label = `Recalled ${memoryCount(memories.length)}`;
116
+ return (
117
+ <MemoryPopover
118
+ anchor={anchor}
119
+ key={anchor.turnId}
120
+ label={label}
121
+ memories={memories}
122
+ state={state}
123
+ trigger={
124
+ <button
125
+ className="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-md border border-dashboard-border-subtle bg-transparent px-2 font-sans text-xs text-dashboard-text-muted transition-colors hover:bg-dashboard-fill-hover hover:text-dashboard-text focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus aria-expanded:bg-dashboard-fill-strong aria-expanded:text-dashboard-text"
126
+ type="button"
127
+ >
128
+ <Brain aria-hidden="true" size={13} strokeWidth={1.8} />
129
+ {label}
130
+ </button>
131
+ }
132
+ />
133
+ );
134
+ })}
135
+ </div>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Show the memories that one Turn captured. Without memory state or a list
141
+ * for the Turn, such as in a nested transcript, show `fallback`.
142
+ */
143
+ export function TranscriptCapturedMemories(props: {
144
+ fallback: ReactNode;
145
+ timestamp?: number;
146
+ turnId?: string;
147
+ }) {
148
+ const state = useConversationMemoryState();
149
+ const anchor = props.turnId
150
+ ? ({ source: "captured", turnId: props.turnId } as const)
151
+ : undefined;
152
+ const memories = anchor ? anchorMemories(state?.list, anchor) : [];
153
+ if (!state || !anchor || memories.length === 0) return props.fallback;
154
+ const label = `Captured ${memoryCount(memories.length)}`;
155
+ const timestamp = formatMessageTimestamp(props.timestamp);
156
+ return (
157
+ <MemoryPopover
158
+ anchor={anchor}
159
+ label={label}
160
+ memories={memories}
161
+ state={state}
162
+ trigger={
163
+ <button
164
+ className="-mx-2 -my-1.5 grid w-[calc(100%+1rem)] cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-start gap-2 rounded-md border-0 bg-transparent px-2 py-1.5 text-left font-sans transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus aria-expanded:bg-dashboard-fill-hover max-md:grid-cols-[minmax(0,1fr)]"
165
+ type="button"
166
+ >
167
+ <span className="text-sm font-medium leading-6 text-dashboard-text">
168
+ {label}
169
+ </span>
170
+ {timestamp ? (
171
+ <span className="text-xs leading-6 tabular-nums text-dashboard-text-muted max-md:hidden">
172
+ {timestamp}
173
+ </span>
174
+ ) : null}
175
+ </button>
176
+ }
177
+ />
178
+ );
179
+ }
@@ -5,6 +5,7 @@ import {
5
5
  } from "./TranscriptAttachment";
6
6
  import { ObjectCard } from "./ObjectCard";
7
7
  import { AutomationCard } from "../components/AutomationCard";
8
+ import { ReviewItemChangeRow } from "../review-desk/ReviewItemChangeRow";
8
9
  import { memo, type ReactNode } from "react";
9
10
 
10
11
  import {
@@ -23,6 +24,7 @@ import { RedactedMarker } from "./TranscriptRedacted";
23
24
  import { SlackMark } from "./SlackMark";
24
25
  import { DisplayText } from "../display-text/DisplayText";
25
26
  import { TranscriptTurnContextView } from "./TranscriptTurnContextView";
27
+ import { TranscriptRecalledMemories } from "./TranscriptMemoryPopover";
26
28
  import { TranscriptTimestamp } from "./TranscriptTimestamp";
27
29
  import { showsSlackSourceIcon } from "./transcriptSource";
28
30
 
@@ -35,6 +37,28 @@ export const TranscriptMessageView = memo(
35
37
  const rawText = messageRawText(props.message);
36
38
  const roleKind = transcriptRoleKind(props.message.role);
37
39
  const chatText = roleKind === "assistant" || roleKind === "user";
40
+ const text = (
41
+ <div
42
+ className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2"
43
+ // `QuickQuestion` reads it: selections here can start a question.
44
+ data-quick-question-message-id={
45
+ canForkAfter(props.message, props.conversation)
46
+ ? props.message.messageId
47
+ : undefined
48
+ }
49
+ >
50
+ {props.message.parts.map((part, index) =>
51
+ part.type === "text" ? (
52
+ <DisplayText
53
+ chat={chatText}
54
+ key={index}
55
+ mentions={props.message.mentions}
56
+ text={part.text ?? ""}
57
+ />
58
+ ) : null,
59
+ )}
60
+ </div>
61
+ );
38
62
 
39
63
  return (
40
64
  <TranscriptMessageShell
@@ -59,18 +83,16 @@ export const TranscriptMessageView = memo(
59
83
  message={props.message}
60
84
  conversation={props.conversation}
61
85
  />
62
- <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-2">
63
- {props.message.parts.map((part, index) =>
64
- part.type === "text" ? (
65
- <DisplayText
66
- chat={chatText}
86
+ {props.message.reviewItemChanges?.length
87
+ ? props.message.reviewItemChanges.map((record, index) => (
88
+ <ReviewItemChangeRow
89
+ fallback={text}
67
90
  key={index}
68
- mentions={props.message.mentions}
69
- text={part.text ?? ""}
91
+ record={record}
70
92
  />
71
- ) : null,
72
- )}
73
- </div>
93
+ ))
94
+ : text}
95
+ <TranscriptRecalledMemories message={props.message} />
74
96
  {props.message.attachments?.length ? (
75
97
  <TranscriptAttachments
76
98
  attachments={props.message.attachments}
@@ -146,12 +168,7 @@ function messageAction(
146
168
  />
147
169
  );
148
170
  }
149
- if (
150
- conversation.canFork &&
151
- message.role === "assistant" &&
152
- message.messageId &&
153
- !message.pending
154
- ) {
171
+ if (canForkAfter(message, conversation)) {
155
172
  return (
156
173
  <ForkConversationButton
157
174
  conversationId={conversation.conversationId}
@@ -162,6 +179,19 @@ function messageAction(
162
179
  return undefined;
163
180
  }
164
181
 
182
+ /** A delivered Junior reply ends the history of a fork or Quick question. */
183
+ function canForkAfter(
184
+ message: TranscriptViewMessage,
185
+ conversation: ConversationTranscript,
186
+ ): message is TranscriptViewMessage & { messageId: string } {
187
+ return Boolean(
188
+ conversation.canFork &&
189
+ message.role === "assistant" &&
190
+ message.messageId &&
191
+ !message.pending,
192
+ );
193
+ }
194
+
165
195
  function TranscriptMessageHeader(props: {
166
196
  action?: ReactNode;
167
197
  message: TranscriptViewMessage;
@@ -35,7 +35,14 @@ export function TranscriptUnreadMarker() {
35
35
  );
36
36
  }
37
37
 
38
- /** Leave room for control outlines inside the mobile row's paint bounds. */
38
+ /**
39
+ * Lay out one transcript row at its real size.
40
+ *
41
+ * Rows do not use `content-visibility: auto`. A skipped row has an estimated
42
+ * size, and it changes size when the reader scrolls near it. Above the
43
+ * reader, each change needs a scroll correction while they scroll, and on
44
+ * phones those corrections make the transcript jump.
45
+ */
39
46
  export function TranscriptRow(props: {
40
47
  /** Stable row id that holds the reader's place while layout changes. */
41
48
  anchorKey?: string;
@@ -46,7 +53,7 @@ export function TranscriptRow(props: {
46
53
  }) {
47
54
  return (
48
55
  <div
49
- className="mobile-transcript-row -mx-2 px-2 py-1.5"
56
+ className="py-1.5"
50
57
  data-scroll-anchor={props.anchorKey}
51
58
  data-transcript-seq={props.seq}
52
59
  >
@@ -1,6 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import type { ConversationMailboxMessage } from "./conversationOutbox";
3
- import { ArrowDownToLine } from "lucide-react";
3
+ import { ArrowDown } from "lucide-react";
4
4
 
5
5
  import type {
6
6
  ConversationTranscript,
@@ -8,11 +8,6 @@ import type {
8
8
  } from "../types";
9
9
  import { EmptyTelemetry } from "../components/EmptyTelemetry";
10
10
  import { Button } from "../components/ui/button";
11
- import {
12
- Tooltip,
13
- TooltipContent,
14
- TooltipTrigger,
15
- } from "../components/ui/tooltip";
16
11
  import { ConversationTranscriptView } from "./ConversationTranscript";
17
12
  import { ConversationEventLog } from "./ConversationEventLog";
18
13
  import {
@@ -191,33 +186,21 @@ function JumpToLatestButton(props: {
191
186
  }) {
192
187
  if (!props.visible) return null;
193
188
 
194
- const label = props.hasPendingUpdate
195
- ? "Jump to latest update"
196
- : "Jump to latest";
197
-
198
189
  return (
199
- <div className="pointer-events-none sticky bottom-3 z-20 flex h-0 justify-center px-3 md:fixed md:inset-x-auto md:bottom-6 md:right-8 md:h-auto md:justify-end md:px-0">
200
- <Tooltip>
201
- <TooltipTrigger
202
- render={
203
- <Button
204
- aria-label={label}
205
- className="pointer-events-auto relative -translate-y-full rounded-lg !border !border-dashboard-border-emphasis !bg-dashboard-surface-active text-dashboard-text shadow-[0_6px_24px_rgba(0,0,0,0.36)] hover:!border-dashboard-border-interactive hover:!bg-dashboard-surface-hover hover:!text-dashboard-text md:translate-y-0"
206
- onClick={props.onClick}
207
- size="icon"
208
- >
209
- <ArrowDownToLine aria-hidden="true" size={16} strokeWidth={2} />
210
- {props.hasPendingUpdate ? (
211
- <span
212
- aria-hidden="true"
213
- className="absolute right-1.5 top-1.5 size-2 bg-emerald-300"
214
- />
215
- ) : null}
216
- </Button>
217
- }
218
- />
219
- <TooltipContent size="compact">{label}</TooltipContent>
220
- </Tooltip>
190
+ // Sticky offsets count from inside the scroll region's bottom padding
191
+ // (pb-4 / md:pb-6). The negative offsets cancel most of it, so the pill
192
+ // sits 12px (16px on md) above the composer, centered on the transcript.
193
+ <div className="pointer-events-none sticky -bottom-2.5 z-20 flex h-0 justify-center md:-bottom-5">
194
+ <Button
195
+ aria-label={
196
+ props.hasPendingUpdate ? "Jump to latest update" : "Jump to latest"
197
+ }
198
+ className="pointer-events-auto h-8 -translate-y-full gap-1.5 rounded-full pl-3 pr-3.5 text-xs shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
199
+ onClick={props.onClick}
200
+ >
201
+ <ArrowDown aria-hidden="true" size={14} strokeWidth={2} />
202
+ Jump to latest
203
+ </Button>
221
204
  </div>
222
205
  );
223
206
  }
@@ -27,8 +27,6 @@ export type UsageCardSplit = {
27
27
  };
28
28
 
29
29
  export type UsageCardProps = {
30
- /** Closing hint, such as where to find the full breakdown. */
31
- footer?: string;
32
30
  /** Muted detail after the title, such as a compaction count. */
33
31
  note?: string;
34
32
  sections: UsageCardSection[];
@@ -64,18 +62,17 @@ export function UsageCard(props: UsageCardProps) {
64
62
  <span
65
63
  className={cn(
66
64
  "grid gap-2.5",
67
- (props.sections.length > 0 || props.footer) &&
68
- "border-b border-dashboard-border pb-3",
65
+ props.sections.length > 0 && "border-b border-dashboard-border pb-3",
69
66
  )}
70
67
  >
71
68
  <span className="flex items-baseline justify-between gap-3">
72
- <span className="min-w-0 truncate">
69
+ <span className="min-w-0 truncate font-medium text-dashboard-text">
73
70
  {props.title}
74
71
  {props.note ? (
75
72
  <span className="text-dashboard-text-faint"> · {props.note}</span>
76
73
  ) : null}
77
74
  </span>
78
- <span className="whitespace-nowrap text-lg font-semibold tabular-nums text-dashboard-text">
75
+ <span className="whitespace-nowrap text-base font-semibold tabular-nums text-dashboard-text">
79
76
  {props.total}
80
77
  </span>
81
78
  </span>
@@ -127,7 +124,7 @@ export function UsageCard(props: UsageCardProps) {
127
124
  <span
128
125
  className={cn(
129
126
  "flex min-w-0 items-baseline gap-2",
130
- row.code && "font-mono text-dashboard-text",
127
+ row.code && "font-mono",
131
128
  )}
132
129
  >
133
130
  <span className="min-w-0 truncate" title={row.label}>
@@ -156,17 +153,6 @@ export function UsageCard(props: UsageCardProps) {
156
153
  ) : null}
157
154
  </span>
158
155
  ))}
159
- {props.footer ? (
160
- <span
161
- className={cn(
162
- "text-dashboard-text-faint",
163
- props.sections.length > 0 &&
164
- "border-t border-dashboard-border pt-3",
165
- )}
166
- >
167
- {props.footer}
168
- </span>
169
- ) : null}
170
156
  </span>
171
157
  );
172
158
  }
@@ -0,0 +1,138 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { createContext, useContext } from "react";
3
+ import { z } from "zod";
4
+
5
+ import { fetchDashboardJson } from "../http";
6
+
7
+ const conversationMemoryListSchema = z
8
+ .object({
9
+ events: z.array(
10
+ z
11
+ .object({
12
+ memoryIds: z.array(z.string().min(1)).min(1),
13
+ source: z.enum(["captured", "recalled"]),
14
+ turnId: z.string().min(1).optional(),
15
+ })
16
+ .strict(),
17
+ ),
18
+ memories: z.array(
19
+ z
20
+ .object({
21
+ content: z.string().min(1),
22
+ id: z.string().min(1),
23
+ visibility: z.enum(["private", "public"]),
24
+ })
25
+ .strict(),
26
+ ),
27
+ })
28
+ .strict();
29
+
30
+ export type ConversationMemoryList = z.output<
31
+ typeof conversationMemoryListSchema
32
+ >;
33
+ export type ConversationMemory = ConversationMemoryList["memories"][number];
34
+ type MemorySource = ConversationMemoryList["events"][number]["source"];
35
+
36
+ /** One transcript spot that recalled or captured memories. */
37
+ export type MemoryAnchor = { source: MemorySource; turnId: string };
38
+
39
+ /** Load what one Conversation recalled and captured. */
40
+ export function useConversationMemories(conversationId: string) {
41
+ return useQuery({
42
+ queryFn: ({ signal }) =>
43
+ fetchDashboardJson(
44
+ conversationMemoryListSchema,
45
+ `/api/plugins/memory/conversations/${encodeURIComponent(conversationId)}/memories`,
46
+ signal,
47
+ ),
48
+ queryKey: ["conversation", conversationId, "memories"],
49
+ retry: false,
50
+ });
51
+ }
52
+
53
+ /** Name the transcript element that opens one anchor's popover. */
54
+ export function memoryAnchorKey(anchor: MemoryAnchor): string {
55
+ return `${anchor.source}:${anchor.turnId}`;
56
+ }
57
+
58
+ /** List the memories that one transcript spot recalled or captured. */
59
+ export function anchorMemories(
60
+ list: ConversationMemoryList | undefined,
61
+ anchor: MemoryAnchor,
62
+ ): ConversationMemory[] {
63
+ const event = list?.events.find(
64
+ (candidate) =>
65
+ candidate.source === anchor.source && candidate.turnId === anchor.turnId,
66
+ );
67
+ if (!event || !list) return [];
68
+ const byId = new Map(list.memories.map((memory) => [memory.id, memory]));
69
+ return event.memoryIds.flatMap((id) => {
70
+ const memory = byId.get(id);
71
+ return memory ? [memory] : [];
72
+ });
73
+ }
74
+
75
+ /** One row of the Conversation memory list. */
76
+ export type ConversationMemoryEntry = {
77
+ memory: ConversationMemory;
78
+ source: MemorySource;
79
+ turnId?: string;
80
+ };
81
+
82
+ /**
83
+ * Flatten recalls and captures into one list in Conversation order. A memory
84
+ * shows once per source, at the first Turn that recalled or captured it.
85
+ */
86
+ export function conversationMemoryEntries(
87
+ list: ConversationMemoryList | undefined,
88
+ ): ConversationMemoryEntry[] {
89
+ if (!list) return [];
90
+ const byId = new Map(list.memories.map((memory) => [memory.id, memory]));
91
+ const seen = new Set<string>();
92
+ const entries: ConversationMemoryEntry[] = [];
93
+ for (const event of list.events) {
94
+ for (const id of event.memoryIds) {
95
+ const key = `${event.source}:${id}`;
96
+ const memory = byId.get(id);
97
+ if (seen.has(key) || !memory) continue;
98
+ seen.add(key);
99
+ entries.push({
100
+ memory,
101
+ source: event.source,
102
+ ...(event.turnId ? { turnId: event.turnId } : undefined),
103
+ });
104
+ }
105
+ }
106
+ return entries;
107
+ }
108
+
109
+ /** The open memory popover, and the memory it highlights. */
110
+ export type OpenMemoryPopover = MemoryAnchor & { memoryId?: string };
111
+
112
+ /** Shared memory popover state for one Conversation page. */
113
+ export type ConversationMemoryState = {
114
+ list?: ConversationMemoryList;
115
+ open?: OpenMemoryPopover;
116
+ setOpen(open: OpenMemoryPopover | undefined): void;
117
+ };
118
+
119
+ /**
120
+ * Let the transcript and the details panel share memory popovers. Only the
121
+ * Conversation page sets it, so nested transcripts show plain events.
122
+ */
123
+ export const ConversationMemoryContext = createContext<
124
+ ConversationMemoryState | undefined
125
+ >(undefined);
126
+
127
+ /** Read the memory popover state of the current Conversation page. */
128
+ export function useConversationMemoryState() {
129
+ return useContext(ConversationMemoryContext);
130
+ }
131
+
132
+ /** True for the memory plugin event that a Turn's capture emits. */
133
+ export function isMemoryCaptureEvent(part: {
134
+ name: string;
135
+ namespace: string;
136
+ }): boolean {
137
+ return part.namespace === "memory" && part.name === "memories_captured";
138
+ }
@@ -7,13 +7,18 @@ export type ConversationSection = {
7
7
  };
8
8
 
9
9
  const DAY_MS = 24 * 60 * 60 * 1000;
10
+ // Sorts before every time section.
11
+ const ATTENTION_SECTION = {
12
+ key: "attention",
13
+ label: "Needs attention",
14
+ order: Number.NEGATIVE_INFINITY,
15
+ };
10
16
 
11
17
  /**
12
- * Group conversations by last activity: Today, Yesterday, Previous 7 days,
13
- * Previous 30 days, then one section per month.
18
+ * Group conversations into Needs attention, then by last activity: Today,
19
+ * Yesterday, Previous 7 days, Previous 30 days, then one section per month.
14
20
  *
15
- * Conversations in `attentionIds` lead their section, so unread and waiting
16
- * rows sit above read rows without leaving their time group.
21
+ * Conversations in `attentionIds` go to Needs attention, newest first.
17
22
  */
18
23
  export function buildConversationSections(
19
24
  conversations: Conversation[],
@@ -25,20 +30,19 @@ export function buildConversationSections(
25
30
  ): ConversationSection[] {
26
31
  const nowDay = calendarDay(options.nowMs, options.timeZone);
27
32
  const sections = new Map<string, ConversationSection & { order: number }>();
28
- const byActivity = [...conversations].sort(
33
+ const sorted = [...conversations].sort(
29
34
  (left, right) => activityTime(right) - activityTime(left),
30
35
  );
31
- const sorted = options.attentionIds
32
- ? attentionFirst(byActivity, options.attentionIds)
33
- : byActivity;
34
36
 
35
37
  for (const conversation of sorted) {
36
- const section = conversationSection(
37
- activityTime(conversation),
38
- nowDay,
39
- options.nowMs,
40
- options.timeZone,
41
- );
38
+ const section = options.attentionIds?.has(conversation.id)
39
+ ? ATTENTION_SECTION
40
+ : conversationSection(
41
+ activityTime(conversation),
42
+ nowDay,
43
+ options.nowMs,
44
+ options.timeZone,
45
+ );
42
46
  const existing = sections.get(section.key);
43
47
  if (existing) {
44
48
  existing.conversations.push(conversation);
@@ -97,6 +97,9 @@ export function pendingTranscriptMessage(
97
97
  ? { actorIdentityId: message.actorIdentityId }
98
98
  : undefined),
99
99
  ...(message.mentions ? { mentions: message.mentions } : undefined),
100
+ ...(message.reviewItemChange
101
+ ? { reviewItemChanges: [message.reviewItemChange] }
102
+ : undefined),
100
103
  };
101
104
  }
102
105
 
@@ -240,6 +243,9 @@ export function transcriptMessagesFromEvents(
240
243
  ...(data.explicitMention !== undefined
241
244
  ? { explicitMention: data.explicitMention }
242
245
  : undefined),
246
+ ...(data.reviewItemChanges
247
+ ? { reviewItemChanges: data.reviewItemChanges }
248
+ : undefined),
243
249
  ...(data.source ? { source: data.source } : undefined),
244
250
  };
245
251
  messages.push(message);
@@ -327,6 +333,7 @@ export function transcriptMessagesFromEvents(
327
333
  kind: data.kind,
328
334
  loadedAt: event.createdAt,
329
335
  pluginName: data.pluginName,
336
+ turnId: data.turnId,
330
337
  version: data.version,
331
338
  });
332
339
  }
@@ -411,6 +418,7 @@ export function transcriptMessagesFromEvents(
411
418
  name: data.name,
412
419
  version: data.version,
413
420
  presentation: data.presentation,
421
+ ...(data.turnId ? { turnId: data.turnId } : undefined),
414
422
  },
415
423
  ]),
416
424
  );