@sentry/junior-dashboard 0.294.0 → 0.295.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +7 -5
- package/dist/client-manifest.json +1 -1
- package/dist/index.js +7 -5
- package/dist/public/chunks/{AdminPage-mn71yp2v.js → AdminPage-lctmhvpg.js} +1 -1
- package/dist/public/chunks/{AnchorHeading-cp3vx8qa.js → AnchorHeading-ixbupfij.js} +1 -1
- package/dist/public/chunks/{Area-h08sxa33.js → Area-d95rfkv5.js} +1 -1
- package/dist/public/chunks/AutomationPage-m78li1hv.js +3 -0
- package/dist/public/chunks/{AutomationRunDetails-jjdhbqtn.js → AutomationRunDetails-dx5lutte.js} +1 -1
- package/dist/public/chunks/{AutomationRunsPage-j50ps7nt.js → AutomationRunsPage-cba6nbxd.js} +1 -1
- package/dist/public/chunks/{AutomationStatus-czfg6ayl.js → AutomationStatus-eqq5lrad.js} +1 -1
- package/dist/public/chunks/AutomationsPage-cp7wtt2v.js +8 -0
- package/dist/public/chunks/{BarChart-e3oi6in1.js → BarChart-lbkezxbi.js} +1 -1
- package/dist/public/chunks/{BoardPage-f0uxqg7u.js → BoardPage-m4zqf62q.js} +1 -1
- package/dist/public/chunks/{CodeChangeRow-dk3gkjut.js → CodeChangeRow-d620nk75.js} +2 -2
- package/dist/public/chunks/CodePage-h2ayars2.js +1 -0
- package/dist/public/chunks/{CodeSummaryCards-o1po2jis.js → CodeSummaryCards-iotfblbp.js} +1 -1
- package/dist/public/chunks/{ConnectionsPage-mholegdk.js → ConnectionsPage-nm7w1i05.js} +2 -2
- package/dist/public/chunks/{DirectoryMetric-dgzufji4.js → DirectoryMetric-huy2ptzz.js} +1 -1
- package/dist/public/chunks/{DrawerContent-ovjgr16m.js → DrawerContent-n3biwp5m.js} +1 -1
- package/dist/public/chunks/{FilterBar-mtnh7fbi.js → FilterBar-dqnt967g.js} +1 -1
- package/dist/public/chunks/FormNotice-kj5pzsl7.js +1 -0
- package/dist/public/chunks/{LocationDetailPage-ggd6zgzp.js → LocationDetailPage-iqtx76w6.js} +2 -2
- package/dist/public/chunks/{LocationsPage-fh6vzxa1.js → LocationsPage-ju5jfejt.js} +2 -2
- package/dist/public/chunks/{McpSettingsPage-fm3fh2ux.js → McpSettingsPage-brav5c42.js} +1 -1
- package/dist/public/chunks/{PeoplePage-odrbcmhj.js → PeoplePage-lfq8nrto.js} +2 -2
- package/dist/public/chunks/{PersonProfilePage-gynexe89.js → PersonProfilePage-ep45sxr6.js} +1 -1
- package/dist/public/chunks/{PersonalTokensPage-4o4zj02w.js → PersonalTokensPage-jbt4q7uz.js} +1 -1
- package/dist/public/chunks/PluginReportRecordSet-ml18z19r.js +1 -0
- package/dist/public/chunks/{PluginUserPage-kwylbbpr.js → PluginUserPage-ilar25ic.js} +2 -2
- package/dist/public/chunks/RepositoryPage-o9m3vty4.js +1 -0
- package/dist/public/chunks/SettingsPage-jc9et1qq.js +1 -0
- package/dist/public/chunks/SystemPage-bbp8cdds.js +15 -0
- package/dist/public/chunks/{WorkspaceFormPage-hwvyvnuk.js → WorkspaceFormPage-kshmsdek.js} +2 -2
- package/dist/public/chunks/{WorkspacesPage-lu9w3ugz.js → WorkspacesPage-ohasc10u.js} +1 -1
- package/dist/public/chunks/alert-ifqo1uj7.js +1 -0
- package/dist/public/chunks/{automationFormat-hd7pnocr.js → automationFormat-kv314fzh.js} +1 -1
- package/dist/public/chunks/{chart-myr1oeen.js → chart-il2ahs5y.js} +1 -1
- package/dist/public/chunks/{circle-dollar-sign-fpl4dmok.js → circle-dollar-sign-h7k4kr7s.js} +1 -1
- package/dist/public/chunks/{code-highlighter-i3ux67or.js → code-highlighter-ea4j0ys1.js} +1 -1
- package/dist/public/chunks/{coins-bqkaxeba.js → coins-l2c8c17w.js} +1 -1
- package/dist/public/chunks/{format-gle1s0ri.js → format-flkutk9e.js} +2 -2
- package/dist/public/chunks/junior-g62bxb2t.js +4 -0
- package/dist/public/chunks/{native-select-jfknh8t4.js → native-select-fxs3p0bj.js} +1 -1
- package/dist/public/chunks/{plus-gndielka.js → plus-54e3zzys.js} +1 -1
- package/dist/public/chunks/vendor-icons-blmhaswc.js +698 -0
- package/dist/public/chunks/{workspaceQueries-m1ksz95c.js → workspaceQueries-onxawhg4.js} +1 -1
- package/dist/public/client-ctcthgam.js +165 -0
- package/dist/public/styles-4CIT7L7K.css +2 -0
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/api.ts +16 -0
- package/src/client/components/MarkdownViewToggle.tsx +24 -0
- package/src/client/components/SkillLink.tsx +119 -0
- package/src/client/conversations/ConversationHomeList.tsx +87 -76
- package/src/client/conversations/ConversationListStatusIcon.tsx +39 -55
- package/src/client/conversations/ConversationSectionList.tsx +101 -0
- package/src/client/conversations/ConversationSidebar.tsx +159 -169
- package/src/client/conversations/ConversationWorkspace.tsx +5 -16
- package/src/client/conversations/TextAttachmentPreview.tsx +5 -14
- package/src/client/conversations/WaitingChip.tsx +78 -0
- package/src/client/conversations/conversationSections.ts +78 -55
- package/src/client/conversations/heldListOrder.ts +170 -0
- package/src/client/conversations/toolCallPreview.ts +3 -1
- package/src/client/conversations/transcriptSource.ts +7 -2
- package/src/client/conversations/useMarkConversationUnread.ts +2 -2
- package/src/client/format.ts +18 -2
- package/src/client/pages/automations/AutomationDraftChanges.tsx +2 -1
- package/src/client/pages/automations/AutomationOverview.tsx +3 -3
- package/src/client/pages/automations/AutomationSkillPicker.tsx +75 -31
- package/src/client/pages/automations/AutomationTaskFields.tsx +3 -13
- package/src/client/pages/automations/AutomationVersionsTab.tsx +2 -5
- package/src/client/pages/automations/skillSearch.ts +50 -0
- package/src/client/pages/code/RepositorySections.tsx +14 -5
- package/src/client/pages/code/RepositoryTabs.tsx +5 -11
- package/src/client/pages/system/PluginDetails.tsx +15 -8
- package/src/client/pages/system/SkillInventory.tsx +2 -6
- package/src/client/review-desk/queries.ts +30 -23
- package/src/client/review-desk/reviewItemFormat.ts +3 -1
- package/src/client/types.ts +5 -1
- package/src/mock-reporting/review-items.ts +6 -2
- package/dist/public/chunks/AutomationPage-e1l74ezg.js +0 -10
- package/dist/public/chunks/AutomationsPage-cphxl4l6.js +0 -8
- package/dist/public/chunks/CodePage-i97ivyji.js +0 -1
- package/dist/public/chunks/FormNotice-eyo2kdte.js +0 -1
- package/dist/public/chunks/PluginReportRecordSet-hm1cwp6c.js +0 -1
- package/dist/public/chunks/RepositoryPage-fti2vfu4.js +0 -1
- package/dist/public/chunks/SettingsPage-kxspx37h.js +0 -1
- package/dist/public/chunks/SystemPage-jurblfgy.js +0 -15
- package/dist/public/chunks/SystemPlugins-jr37f1cv.js +0 -1
- package/dist/public/chunks/junior-hdb4wc4q.js +0 -4
- package/dist/public/chunks/vendor-icons-mxz7yi04.js +0 -692
- package/dist/public/client-f7asutg5.js +0 -165
- package/dist/public/styles-ZJJGBKKX.css +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentry/junior-dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.295.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"remark-gfm": "^4.0.1",
|
|
46
46
|
"shiki": "4.1.0",
|
|
47
47
|
"zod": "^4.5.4",
|
|
48
|
-
"@sentry/junior-plugin-api": "0.
|
|
48
|
+
"@sentry/junior-plugin-api": "0.295.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@sentry/junior": "0.
|
|
51
|
+
"@sentry/junior": "0.295.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@fontsource-variable/inter": "^5.3.0",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"typescript": "^7.0.2",
|
|
73
73
|
"unrun": "^0.3.1",
|
|
74
74
|
"vitest": "^4.1.12",
|
|
75
|
-
"@sentry/junior": "0.
|
|
75
|
+
"@sentry/junior": "0.295.0"
|
|
76
76
|
},
|
|
77
77
|
"scripts": {
|
|
78
78
|
"build": "pnpm --filter @sentry/junior-plugin-api build && pnpm --filter @sentry/junior build && pnpm run build:css && pnpm run build:client && pnpm run build:server",
|
package/src/client/api.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import {
|
|
31
31
|
pluginOperationalReportFeedSchema,
|
|
32
32
|
pluginsSchema,
|
|
33
|
+
skillDetailReportSchema,
|
|
33
34
|
skillReportsSchema,
|
|
34
35
|
} from "@sentry/junior/api/schema";
|
|
35
36
|
import { pluginUserPageLinksSchema } from "@sentry/junior-plugin-api";
|
|
@@ -388,6 +389,21 @@ export function useSkillsData() {
|
|
|
388
389
|
});
|
|
389
390
|
}
|
|
390
391
|
|
|
392
|
+
/** Fetch one skill with its SKILL.md body. */
|
|
393
|
+
export function useSkillDetail(name: string) {
|
|
394
|
+
return useQuery({
|
|
395
|
+
queryKey: ["dashboard", "skills", name],
|
|
396
|
+
queryFn: ({ signal }) =>
|
|
397
|
+
fetchDashboardJson(
|
|
398
|
+
skillDetailReportSchema,
|
|
399
|
+
`/api/skills/${encodeURIComponent(name)}`,
|
|
400
|
+
signal,
|
|
401
|
+
),
|
|
402
|
+
retry: false,
|
|
403
|
+
staleTime: dashboardMetadataStaleTimeMs,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
391
407
|
/** Fetch operational plugin reports used by System navigation and pages. */
|
|
392
408
|
export function usePluginReportsData() {
|
|
393
409
|
return useQuery({
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
|
2
|
+
|
|
3
|
+
/** How a drawer shows Markdown text. */
|
|
4
|
+
export type MarkdownView = "formatted" | "raw";
|
|
5
|
+
|
|
6
|
+
/** Switch a Markdown preview between formatted and raw text. */
|
|
7
|
+
export function MarkdownViewToggle(props: {
|
|
8
|
+
onChange(view: MarkdownView): void;
|
|
9
|
+
value: MarkdownView;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<ToggleGroup
|
|
13
|
+
aria-label="Markdown view"
|
|
14
|
+
onValueChange={(values) => {
|
|
15
|
+
const next = values[0];
|
|
16
|
+
if (next) props.onChange(next);
|
|
17
|
+
}}
|
|
18
|
+
value={[props.value]}
|
|
19
|
+
>
|
|
20
|
+
<ToggleGroupItem value="formatted">Formatted</ToggleGroupItem>
|
|
21
|
+
<ToggleGroupItem value="raw">Raw</ToggleGroupItem>
|
|
22
|
+
</ToggleGroup>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useId, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useSkillDetail } from "../api";
|
|
4
|
+
import { HighlightedCode } from "../code";
|
|
5
|
+
import { DisplayText } from "../display-text/DisplayText";
|
|
6
|
+
import { DashboardApiError } from "../http";
|
|
7
|
+
import { cn } from "../styles";
|
|
8
|
+
import { Drawer } from "./Drawer";
|
|
9
|
+
import { InlineError } from "./InlineError";
|
|
10
|
+
import { MarkdownViewToggle, type MarkdownView } from "./MarkdownViewToggle";
|
|
11
|
+
import { Skeleton } from "./ui/skeleton";
|
|
12
|
+
|
|
13
|
+
const SKELETON_LINE_WIDTHS = ["w-3/4", "w-full", "w-5/6", "w-2/3", "w-full"];
|
|
14
|
+
|
|
15
|
+
/** Write a skill name the way people invoke it, such as `$self-update`. */
|
|
16
|
+
export function skillLabel(name: string): string {
|
|
17
|
+
return `$${name}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Name a skill as `$skill-name` and open its SKILL.md in a drawer on click.
|
|
22
|
+
* Pass children to replace the label, such as "View skill". Set `chip` in
|
|
23
|
+
* skill lists.
|
|
24
|
+
*/
|
|
25
|
+
export function SkillLink(props: {
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
chip?: boolean;
|
|
28
|
+
name: string;
|
|
29
|
+
}) {
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<button
|
|
34
|
+
className={cn(
|
|
35
|
+
"cursor-pointer text-dashboard-accent-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dashboard-focus",
|
|
36
|
+
props.chip
|
|
37
|
+
? "rounded-md border border-dashboard-border bg-dashboard-overlay-soft px-2.5 py-1.5 text-xs hover:border-dashboard-border-heavy"
|
|
38
|
+
: "border-0 bg-transparent p-0 hover:underline",
|
|
39
|
+
props.children ? "font-sans" : "font-mono",
|
|
40
|
+
)}
|
|
41
|
+
onClick={() => setOpen(true)}
|
|
42
|
+
type="button"
|
|
43
|
+
>
|
|
44
|
+
{props.children ?? skillLabel(props.name)}
|
|
45
|
+
</button>
|
|
46
|
+
{open ? (
|
|
47
|
+
<SkillDrawer name={props.name} onClose={() => setOpen(false)} />
|
|
48
|
+
) : null}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SkillDrawer(props: { name: string; onClose(): void }) {
|
|
54
|
+
const titleId = useId();
|
|
55
|
+
const [view, setView] = useState<MarkdownView>("formatted");
|
|
56
|
+
const skill = useSkillDetail(props.name);
|
|
57
|
+
const missing =
|
|
58
|
+
skill.error instanceof DashboardApiError && skill.error.status === 404;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Drawer
|
|
62
|
+
actions={
|
|
63
|
+
skill.data ? (
|
|
64
|
+
<MarkdownViewToggle onChange={setView} value={view} />
|
|
65
|
+
) : null
|
|
66
|
+
}
|
|
67
|
+
closeLabel={`Close ${skillLabel(props.name)}`}
|
|
68
|
+
header={
|
|
69
|
+
<div className="min-w-0 [overflow-wrap:anywhere]">
|
|
70
|
+
<h2
|
|
71
|
+
className="m-0 font-mono text-sm font-semibold text-dashboard-text"
|
|
72
|
+
id={titleId}
|
|
73
|
+
>
|
|
74
|
+
{skillLabel(props.name)}
|
|
75
|
+
</h2>
|
|
76
|
+
{skill.data ? (
|
|
77
|
+
<div className="font-sans text-xs text-dashboard-text-muted">
|
|
78
|
+
{skill.data.pluginProvider
|
|
79
|
+
? `From the ${skill.data.pluginProvider} plugin`
|
|
80
|
+
: "App or local skill"}
|
|
81
|
+
{skill.data.disableModelInvocation ? " · Manual only" : ""}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
}
|
|
86
|
+
onClose={props.onClose}
|
|
87
|
+
openKey={titleId}
|
|
88
|
+
titleId={titleId}
|
|
89
|
+
width="wide"
|
|
90
|
+
>
|
|
91
|
+
{skill.isPending ? (
|
|
92
|
+
<div aria-label="Loading skill" className="grid gap-2" role="status">
|
|
93
|
+
{SKELETON_LINE_WIDTHS.map((width, index) => (
|
|
94
|
+
<Skeleton className={`h-4 ${width}`} key={index} />
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
) : skill.isError ? (
|
|
98
|
+
<InlineError>
|
|
99
|
+
{missing
|
|
100
|
+
? "This skill is no longer installed."
|
|
101
|
+
: "Could not load this skill."}
|
|
102
|
+
</InlineError>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="grid min-w-0 gap-4">
|
|
105
|
+
<p className="m-0 text-sm text-dashboard-text-muted">
|
|
106
|
+
{skill.data.description}
|
|
107
|
+
</p>
|
|
108
|
+
<div className="border-t border-dashboard-border-subtle pt-4">
|
|
109
|
+
{view === "formatted" ? (
|
|
110
|
+
<DisplayText document text={skill.data.body} />
|
|
111
|
+
) : (
|
|
112
|
+
<HighlightedCode code={skill.data.body} language="markdown" />
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</Drawer>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -20,24 +20,24 @@ import {
|
|
|
20
20
|
ParticipantAvatarStack,
|
|
21
21
|
} from "../components/ParticipantAvatarStack";
|
|
22
22
|
import { cn } from "../styles";
|
|
23
|
+
import type { WaitingConversation } from "../review-desk/queries";
|
|
23
24
|
import type { Conversation } from "../types";
|
|
24
25
|
import { useConversationActionToasts } from "./conversationActionToasts";
|
|
25
26
|
import { formatActivityDay } from "./conversationAge";
|
|
26
27
|
import { conversationCardTags } from "./ConversationContextTags";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
ConversationListStatusIcon,
|
|
30
|
+
ConversationStatusText,
|
|
31
|
+
} from "./ConversationListStatusIcon";
|
|
28
32
|
import { conversationPath } from "./conversationRoutes";
|
|
29
33
|
import { useArchiveConversation } from "./conversationArchive";
|
|
30
34
|
import { DisplayText } from "../display-text/DisplayText";
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
buildConversationSections,
|
|
34
|
-
type ConversationSection,
|
|
35
|
-
} from "./conversationSections";
|
|
35
|
+
import type { ConversationSection } from "./conversationSections";
|
|
36
|
+
import { ConversationSectionList } from "./ConversationSectionList";
|
|
36
37
|
import { ConversationSectionHeading } from "./ConversationSectionHeading";
|
|
37
38
|
import { usePrefetchConversation } from "./conversationPrefetch";
|
|
38
39
|
import { useCollapsedConversationSections } from "./useCollapsedConversationSections";
|
|
39
40
|
import {
|
|
40
|
-
PinnedConversationOrder,
|
|
41
41
|
usePinnedRowDrag,
|
|
42
42
|
type PinnedRowDrag,
|
|
43
43
|
} from "./PinnedConversationOrder";
|
|
@@ -51,7 +51,7 @@ export function ConversationHomeList(props: {
|
|
|
51
51
|
/** Leave unset when the list shows only some pins. */
|
|
52
52
|
onReorderPins?: ReorderPins;
|
|
53
53
|
timeZone: string;
|
|
54
|
-
|
|
54
|
+
waitingConversations: ReadonlyMap<string, WaitingConversation>;
|
|
55
55
|
}) {
|
|
56
56
|
const { collapsedKeys, toggle: toggleSection } =
|
|
57
57
|
useCollapsedConversationSections();
|
|
@@ -63,49 +63,48 @@ export function ConversationHomeList(props: {
|
|
|
63
63
|
[showActionError],
|
|
64
64
|
);
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (props.conversations.length === 0) {
|
|
71
|
-
return (
|
|
72
|
-
<div className="rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint p-4">
|
|
73
|
-
<EmptyTelemetry>
|
|
74
|
-
{props.emptyLabel ?? "No conversations match this view."}
|
|
75
|
-
</EmptyTelemetry>
|
|
76
|
-
</div>
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const sections = buildConversationSections(props.conversations, {
|
|
81
|
-
attentionIds: attentionConversationIds(
|
|
82
|
-
props.conversations,
|
|
83
|
-
props.waitingConversationIds,
|
|
84
|
-
),
|
|
85
|
-
nowMs: Date.now(),
|
|
86
|
-
timeZone: props.timeZone,
|
|
87
|
-
});
|
|
88
|
-
const pinnedSection = sections.find((section) => section.key === "pinned");
|
|
66
|
+
// The list stays mounted through the first load, so a pointer that rests
|
|
67
|
+
// on it then still holds the order.
|
|
89
68
|
return (
|
|
90
|
-
<
|
|
91
|
-
conversations={
|
|
92
|
-
|
|
93
|
-
|
|
69
|
+
<ConversationSectionList
|
|
70
|
+
conversations={props.conversations}
|
|
71
|
+
loading={props.loading ?? false}
|
|
72
|
+
onReorderError={handleReorderError}
|
|
73
|
+
onReorderPins={props.onReorderPins}
|
|
74
|
+
timeZone={props.timeZone}
|
|
75
|
+
waitingConversations={props.waitingConversations}
|
|
94
76
|
>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
77
|
+
{({ releaseHold, sections }) =>
|
|
78
|
+
props.loading && props.conversations.length === 0 ? (
|
|
79
|
+
<ConversationHomeListLoading label="Loading conversations" />
|
|
80
|
+
) : props.conversations.length === 0 ? (
|
|
81
|
+
<div className="rounded-lg border border-dashboard-border-subtle bg-dashboard-fill-faint p-4">
|
|
82
|
+
<EmptyTelemetry>
|
|
83
|
+
{props.emptyLabel ?? "No conversations match this view."}
|
|
84
|
+
</EmptyTelemetry>
|
|
85
|
+
</div>
|
|
86
|
+
) : (
|
|
87
|
+
<div
|
|
88
|
+
aria-label="Your conversations"
|
|
89
|
+
className="grid gap-5"
|
|
90
|
+
role="list"
|
|
91
|
+
>
|
|
92
|
+
{sections.map((section) => (
|
|
93
|
+
<ConversationCardSection
|
|
94
|
+
collapsed={collapsedKeys.has(section.key)}
|
|
95
|
+
key={section.key}
|
|
96
|
+
onArchiveError={showArchiveError}
|
|
97
|
+
onArchived={showArchived}
|
|
98
|
+
onRowAction={releaseHold}
|
|
99
|
+
onToggle={() => toggleSection(section.key)}
|
|
100
|
+
waitingConversations={props.waitingConversations}
|
|
101
|
+
section={section}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
</ConversationSectionList>
|
|
109
108
|
);
|
|
110
109
|
}
|
|
111
110
|
|
|
@@ -176,9 +175,10 @@ function ConversationCardSection(props: {
|
|
|
176
175
|
collapsed: boolean;
|
|
177
176
|
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
|
|
178
177
|
onArchived(conversation: Conversation): void;
|
|
178
|
+
onRowAction(): void;
|
|
179
179
|
onToggle(): void;
|
|
180
180
|
section: ConversationSection;
|
|
181
|
-
|
|
181
|
+
waitingConversations: ReadonlyMap<string, WaitingConversation>;
|
|
182
182
|
}) {
|
|
183
183
|
return (
|
|
184
184
|
<section aria-labelledby={`conversation-section-${props.section.key}`}>
|
|
@@ -202,9 +202,8 @@ function ConversationCardSection(props: {
|
|
|
202
202
|
conversation,
|
|
203
203
|
onArchiveError: props.onArchiveError,
|
|
204
204
|
onArchived: props.onArchived,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
),
|
|
205
|
+
onRowAction: props.onRowAction,
|
|
206
|
+
waitingOnViewer: props.waitingConversations.has(conversation.id),
|
|
208
207
|
};
|
|
209
208
|
return conversation.pinnedAt ? (
|
|
210
209
|
<PinnedConversationCard {...card} key={conversation.id} />
|
|
@@ -224,6 +223,8 @@ type ConversationCardProps = {
|
|
|
224
223
|
drag?: PinnedRowDrag;
|
|
225
224
|
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
|
|
226
225
|
onArchived(conversation: Conversation): void;
|
|
226
|
+
/** Lets the list show the result of a row action at once. */
|
|
227
|
+
onRowAction(): void;
|
|
227
228
|
waitingOnViewer: boolean;
|
|
228
229
|
};
|
|
229
230
|
|
|
@@ -250,13 +251,11 @@ function ConversationCard(props: ConversationCardProps) {
|
|
|
250
251
|
const prefetch = usePrefetchConversation(conversation.id);
|
|
251
252
|
const VisibilityIcon = isPrivate ? LockKeyhole : MessageSquare;
|
|
252
253
|
const unread = Boolean(conversation.unread);
|
|
253
|
-
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
status === "active" ||
|
|
259
|
-
status === "failed";
|
|
254
|
+
const emphasized = props.waitingOnViewer || unread;
|
|
255
|
+
// Only read state gets a badge. A running card shows its spinner in place
|
|
256
|
+
// of the time, and failed and stopped are text beside the time.
|
|
257
|
+
const showStatus = emphasized && status !== "active";
|
|
258
|
+
const activityAt = conversation.lastMessageAt ?? conversation.lastSeenAt;
|
|
260
259
|
return (
|
|
261
260
|
<article
|
|
262
261
|
{...prefetch}
|
|
@@ -292,8 +291,6 @@ function ConversationCard(props: ConversationCardProps) {
|
|
|
292
291
|
{showStatus ? (
|
|
293
292
|
<span className="absolute -bottom-1 -right-1 grid size-3.5 place-items-center rounded-full bg-dashboard-surface-panel">
|
|
294
293
|
<ConversationListStatusIcon
|
|
295
|
-
// The tile already shows the lock.
|
|
296
|
-
isPrivate={false}
|
|
297
294
|
status={status}
|
|
298
295
|
unread={unread}
|
|
299
296
|
waitingOnViewer={props.waitingOnViewer}
|
|
@@ -303,7 +300,12 @@ function ConversationCard(props: ConversationCardProps) {
|
|
|
303
300
|
</span>
|
|
304
301
|
<div className="grid min-w-0">
|
|
305
302
|
<div className="flex min-w-0 items-center gap-3">
|
|
306
|
-
<h4
|
|
303
|
+
<h4
|
|
304
|
+
className={cn(
|
|
305
|
+
"pointer-events-none relative z-[1] m-0 min-w-0 flex-1 truncate font-display text-sm leading-6 text-dashboard-text",
|
|
306
|
+
emphasized ? "font-semibold" : "font-medium",
|
|
307
|
+
)}
|
|
308
|
+
>
|
|
307
309
|
{title}
|
|
308
310
|
</h4>
|
|
309
311
|
{participants.length > 0 ? (
|
|
@@ -321,27 +323,36 @@ function ConversationCard(props: ConversationCardProps) {
|
|
|
321
323
|
/>
|
|
322
324
|
</span>
|
|
323
325
|
) : (
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
326
|
+
<>
|
|
327
|
+
{/* The tile already shows the lock. */}
|
|
328
|
+
<ConversationStatusText
|
|
329
|
+
className="pointer-events-none relative z-[1]"
|
|
330
|
+
isPrivate={false}
|
|
331
|
+
status={status}
|
|
332
|
+
/>
|
|
333
|
+
<time
|
|
334
|
+
className="pointer-events-none relative z-[1] min-w-14 whitespace-nowrap text-right font-sans text-xs tabular-nums text-dashboard-text-muted"
|
|
335
|
+
dateTime={activityAt}
|
|
336
|
+
title={formatTime(activityAt, {
|
|
337
|
+
dateStyle: "medium",
|
|
338
|
+
timeStyle: "short",
|
|
339
|
+
})}
|
|
340
|
+
>
|
|
341
|
+
{formatActivityDay(activityAt)}
|
|
342
|
+
</time>
|
|
343
|
+
</>
|
|
334
344
|
)}
|
|
335
345
|
<button
|
|
336
346
|
aria-label={`${conversation.archivedAt ? "Restore" : "Archive"} ${title}`}
|
|
337
347
|
className="relative z-[1] shrink-0 cursor-pointer border-0 bg-transparent p-0 text-dashboard-text-muted transition before:absolute before:-inset-2 hover:text-dashboard-text focus:outline-none focus:ring-2 focus:ring-dashboard-focus focus:ring-offset-2 focus:ring-offset-dashboard-canvas disabled:cursor-not-allowed disabled:opacity-50"
|
|
338
348
|
disabled={archive.isPending}
|
|
339
|
-
onClick={() =>
|
|
349
|
+
onClick={() => {
|
|
350
|
+
props.onRowAction();
|
|
340
351
|
archive.mutate({
|
|
341
352
|
archived: !conversation.archivedAt,
|
|
342
353
|
lastSeenAt: conversation.lastSeenAt,
|
|
343
|
-
})
|
|
344
|
-
}
|
|
354
|
+
});
|
|
355
|
+
}}
|
|
345
356
|
type="button"
|
|
346
357
|
>
|
|
347
358
|
{conversation.archivedAt ? (
|
|
@@ -5,78 +5,62 @@ import { cn } from "../styles";
|
|
|
5
5
|
import type { VisualStatus } from "../types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Render
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Render the row marker. Rows use three channels: the section for position,
|
|
9
|
+
* a dot with a bold title for read state, and a spinner for a running Turn.
|
|
10
|
+
* Waiting rows use the same dot as unread rows; their section and chip say
|
|
11
|
+
* why. Private, failed, and stopped go in `ConversationStatusText`.
|
|
11
12
|
*/
|
|
12
13
|
export function ConversationListStatusIcon(props: {
|
|
13
|
-
isPrivate: boolean;
|
|
14
14
|
status: VisualStatus;
|
|
15
15
|
unread: boolean;
|
|
16
16
|
waitingOnViewer: boolean;
|
|
17
17
|
}) {
|
|
18
|
-
if (props.waitingOnViewer) {
|
|
19
|
-
return (
|
|
20
|
-
<span
|
|
21
|
-
aria-label="Waiting on you"
|
|
22
|
-
className="size-2 shrink-0 rounded-full bg-violet-300 shadow-[0_0_6px_rgba(196,181,253,0.7)]"
|
|
23
|
-
role="img"
|
|
24
|
-
/>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
18
|
if (props.status === "active") {
|
|
29
19
|
return <Spinner aria-label="Running" className="size-3 text-emerald-300" />;
|
|
30
20
|
}
|
|
31
|
-
|
|
32
|
-
if (props.unread) {
|
|
33
|
-
return (
|
|
34
|
-
<span
|
|
35
|
-
aria-label="Unread"
|
|
36
|
-
className="size-1.5 shrink-0 rounded-full bg-orange-300"
|
|
37
|
-
role="img"
|
|
38
|
-
/>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (props.isPrivate) {
|
|
43
|
-
return (
|
|
44
|
-
<LockKeyhole
|
|
45
|
-
aria-label="Private conversation"
|
|
46
|
-
className={cn(
|
|
47
|
-
"size-3 shrink-0",
|
|
48
|
-
props.status === "failed" && "text-rose-300",
|
|
49
|
-
(props.status === "idle" || props.status === "stopped") &&
|
|
50
|
-
"text-dashboard-text-muted",
|
|
51
|
-
)}
|
|
52
|
-
/>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (props.status === "stopped") {
|
|
57
|
-
return (
|
|
58
|
-
<span
|
|
59
|
-
aria-label="Stopped"
|
|
60
|
-
className="size-1.5 shrink-0 bg-dashboard-text-muted"
|
|
61
|
-
role="img"
|
|
62
|
-
/>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (props.status === "failed") {
|
|
21
|
+
if (props.unread || props.waitingOnViewer) {
|
|
67
22
|
return (
|
|
68
23
|
<span
|
|
69
|
-
aria-label="
|
|
70
|
-
className="size-1.5 shrink-0 rounded-full bg-
|
|
24
|
+
aria-label={props.waitingOnViewer ? "Waiting on you" : "Unread"}
|
|
25
|
+
className="size-1.5 shrink-0 rounded-full bg-dashboard-accent"
|
|
71
26
|
role="img"
|
|
72
27
|
/>
|
|
73
28
|
);
|
|
74
29
|
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
75
32
|
|
|
33
|
+
/** Secondary row text for a private, failed, or stopped conversation. */
|
|
34
|
+
export function ConversationStatusText(props: {
|
|
35
|
+
className?: string;
|
|
36
|
+
isPrivate: boolean;
|
|
37
|
+
status: VisualStatus;
|
|
38
|
+
}) {
|
|
39
|
+
if (
|
|
40
|
+
!props.isPrivate &&
|
|
41
|
+
props.status !== "failed" &&
|
|
42
|
+
props.status !== "stopped"
|
|
43
|
+
)
|
|
44
|
+
return null;
|
|
76
45
|
return (
|
|
77
46
|
<span
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
47
|
+
className={cn(
|
|
48
|
+
"inline-flex shrink-0 items-center gap-1 font-sans text-xs",
|
|
49
|
+
props.className,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
{props.isPrivate ? (
|
|
53
|
+
<LockKeyhole
|
|
54
|
+
aria-label="Private conversation"
|
|
55
|
+
className="size-3 shrink-0 text-dashboard-text-faint"
|
|
56
|
+
role="img"
|
|
57
|
+
/>
|
|
58
|
+
) : null}
|
|
59
|
+
{props.status === "failed" ? (
|
|
60
|
+
<span className="text-rose-400">Failed</span>
|
|
61
|
+
) : props.status === "stopped" ? (
|
|
62
|
+
<span className="text-dashboard-text-muted">Stopped</span>
|
|
63
|
+
) : null}
|
|
64
|
+
</span>
|
|
81
65
|
);
|
|
82
66
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useCallback, useMemo, type ReactNode } from "react";
|
|
2
|
+
import { ArrowUp } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
useWaitingConversationsSettled,
|
|
6
|
+
type WaitingConversation,
|
|
7
|
+
} from "../review-desk/queries";
|
|
8
|
+
import type { Conversation } from "../types";
|
|
9
|
+
import {
|
|
10
|
+
buildConversationSections,
|
|
11
|
+
type ConversationSection,
|
|
12
|
+
} from "./conversationSections";
|
|
13
|
+
import { useHeldListOrder } from "./heldListOrder";
|
|
14
|
+
import { PinnedConversationOrder } from "./PinnedConversationOrder";
|
|
15
|
+
import type { ReorderPins } from "./useReorderPinnedConversations";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lay out a conversation list in its sections and hold the order while the
|
|
19
|
+
* reader points at it or works in it. The sidebar and the home list render
|
|
20
|
+
* their rows inside this, so both group, sort, and hold the same way. Row
|
|
21
|
+
* actions call `releaseHold`, so their result shows at once.
|
|
22
|
+
*/
|
|
23
|
+
export function ConversationSectionList(props: {
|
|
24
|
+
children(view: {
|
|
25
|
+
releaseHold(): void;
|
|
26
|
+
sections: ConversationSection[];
|
|
27
|
+
}): ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
conversations: Conversation[];
|
|
30
|
+
loading: boolean;
|
|
31
|
+
onReorderError(conversation: Conversation): void;
|
|
32
|
+
/** Leave unset when the list shows only some pins. */
|
|
33
|
+
onReorderPins?: ReorderPins;
|
|
34
|
+
timeZone: string;
|
|
35
|
+
waitingConversations: ReadonlyMap<string, WaitingConversation>;
|
|
36
|
+
}) {
|
|
37
|
+
// Rebuild sections only when their inputs change. Avoid fresh Date.now()
|
|
38
|
+
// arrays on unrelated parent renders while the reader is scrolling.
|
|
39
|
+
const latestSections = useMemo(
|
|
40
|
+
() =>
|
|
41
|
+
buildConversationSections(props.conversations, {
|
|
42
|
+
nowMs: Date.now(),
|
|
43
|
+
timeZone: props.timeZone,
|
|
44
|
+
waiting: props.waitingConversations,
|
|
45
|
+
}),
|
|
46
|
+
[props.conversations, props.timeZone, props.waitingConversations],
|
|
47
|
+
);
|
|
48
|
+
const waitingSettled = useWaitingConversationsSettled();
|
|
49
|
+
const held = useHeldListOrder(latestSections, {
|
|
50
|
+
settled: !props.loading && waitingSettled,
|
|
51
|
+
});
|
|
52
|
+
const { releaseHold } = held;
|
|
53
|
+
const { onReorderPins } = props;
|
|
54
|
+
const reorderPins = useCallback<ReorderPins>(
|
|
55
|
+
(move) => {
|
|
56
|
+
releaseHold();
|
|
57
|
+
onReorderPins?.(move);
|
|
58
|
+
},
|
|
59
|
+
[onReorderPins, releaseHold],
|
|
60
|
+
);
|
|
61
|
+
const pinned =
|
|
62
|
+
held.sections.find((section) => section.key === "pinned")?.conversations ??
|
|
63
|
+
[];
|
|
64
|
+
return (
|
|
65
|
+
<div {...held.holdProps} className={props.className}>
|
|
66
|
+
<ListUpdatesPill count={held.pending} onApply={held.applyPending} />
|
|
67
|
+
<PinnedConversationOrder
|
|
68
|
+
conversations={pinned}
|
|
69
|
+
onError={props.onReorderError}
|
|
70
|
+
reorder={onReorderPins ? reorderPins : undefined}
|
|
71
|
+
>
|
|
72
|
+
{props.children({ releaseHold, sections: held.sections })}
|
|
73
|
+
</PinnedConversationOrder>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Offer the order that the held list keeps back. The list holds its order
|
|
80
|
+
* while the reader points at it or works in it; this applies the new order.
|
|
81
|
+
*/
|
|
82
|
+
function ListUpdatesPill(props: { count: number; onApply(): void }) {
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
aria-live="polite"
|
|
86
|
+
// Above the sticky section headings of the home list.
|
|
87
|
+
className="pointer-events-none sticky top-0 z-20 flex h-0 items-start justify-center"
|
|
88
|
+
>
|
|
89
|
+
{props.count > 0 ? (
|
|
90
|
+
<button
|
|
91
|
+
className="pointer-events-auto mt-1 inline-flex cursor-pointer items-center gap-1 rounded-full border border-dashboard-border-strong bg-dashboard-surface-raised px-2.5 py-1 font-sans text-xs font-medium text-dashboard-text shadow-sm duration-150 hover:border-dashboard-border-interactive focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-dashboard-focus motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1"
|
|
92
|
+
onClick={props.onApply}
|
|
93
|
+
type="button"
|
|
94
|
+
>
|
|
95
|
+
<ArrowUp aria-hidden="true" className="size-3" />
|
|
96
|
+
{props.count} updated
|
|
97
|
+
</button>
|
|
98
|
+
) : null}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|