@anthusai/papyrus 2.0.0-next.85 → 2.0.0-next.87
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/components/automation-view.tsx +4 -1
- package/components/newsroom-access-gate.tsx +1 -1
- package/components/newsroom-article-editor.tsx +5 -5
- package/components/newsroom-assignment-detail.tsx +6 -6
- package/components/newsroom-ops-overview.tsx +5 -5
- package/components/newsroom-organizer.css +2 -2
- package/components/notification-center.tsx +11 -7
- package/lib/automation-overview.ts +14 -2
- package/lib/review-client.ts +18 -0
- package/lib/review-requests.ts +6 -0
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
statusText,
|
|
16
16
|
storyHref,
|
|
17
17
|
triggerText,
|
|
18
|
+
toolsText,
|
|
18
19
|
whoText,
|
|
19
20
|
type AutomationOutsideRun,
|
|
20
21
|
type AutomationRecord,
|
|
@@ -46,8 +47,10 @@ function RunLine({ run, demo }: { run: AutomationRun; demo: boolean }) {
|
|
|
46
47
|
{run.runRecord === "unavailable" ? " (the run record could not be read)" : ""}
|
|
47
48
|
</span>
|
|
48
49
|
<span>
|
|
49
|
-
{run.storyId ? <>Story:
|
|
50
|
+
{run.storyId && run.storyExists === false ? <>Story: {run.storyTitle ?? run.storyId} (story deleted)</>
|
|
51
|
+
: run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)} data-automation-story-link>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
|
|
50
52
|
</span>
|
|
53
|
+
<span data-automation-run-tools>{toolsText(run)}</span>
|
|
51
54
|
<span data-automation-run-cost data-over-budget={run.overBudget || undefined}>
|
|
52
55
|
<span aria-hidden="true">{run.overBudget ? "! " : ""}</span>{costText(run)}
|
|
53
56
|
</span>
|
|
@@ -57,7 +57,7 @@ export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell:
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function formatAccessTitle(state: NewsDeskShellState | null): string {
|
|
60
|
-
if (!state || state.phase === "checkingAccess") return "Checking
|
|
60
|
+
if (!state || state.phase === "checkingAccess") return "Checking Credentials";
|
|
61
61
|
if (state.phase === "loadingDesk") return "Loading Newsroom Records";
|
|
62
62
|
if (state.phase === "forbidden") return "Editor Role Required";
|
|
63
63
|
if (state.phase === "error") return "Newsroom Unavailable";
|
|
@@ -670,21 +670,21 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
670
670
|
</Select>
|
|
671
671
|
</label>
|
|
672
672
|
<label className="space-y-1 text-sm font-medium">
|
|
673
|
-
<span>
|
|
673
|
+
<span>Section</span>
|
|
674
674
|
<Select
|
|
675
675
|
onValueChange={(value) => {
|
|
676
676
|
if (typeof value === "string") patchEditor({ section: value === NO_DESK_VALUE ? "" : value });
|
|
677
677
|
}}
|
|
678
678
|
value={deskOptions.some(option => option.value === editor.section) ? editor.section : NO_DESK_VALUE}
|
|
679
679
|
>
|
|
680
|
-
<SelectTrigger aria-label="
|
|
681
|
-
<SelectValue>{deskOptions.find(option => option.value === editor.section)?.label ?? "No
|
|
680
|
+
<SelectTrigger aria-label="Section" className="w-full" data-newsroom-article-section>
|
|
681
|
+
<SelectValue>{deskOptions.find(option => option.value === editor.section)?.label ?? "No section"}</SelectValue>
|
|
682
682
|
</SelectTrigger>
|
|
683
683
|
<SelectContent>
|
|
684
|
-
<SelectItem value={NO_DESK_VALUE}>No
|
|
684
|
+
<SelectItem value={NO_DESK_VALUE}>No section</SelectItem>
|
|
685
685
|
{(["core", "rotating"] as const).map((kind) => deskOptions.some(option => option.kind === kind) ? (
|
|
686
686
|
<SelectGroup key={kind}>
|
|
687
|
-
<SelectLabel>{kind === "core" ? "Core
|
|
687
|
+
<SelectLabel>{kind === "core" ? "Core sections" : "Rotating sections"}</SelectLabel>
|
|
688
688
|
{deskOptions.filter(option => option.kind === kind).map((option) => (
|
|
689
689
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
|
690
690
|
))}
|
|
@@ -36,7 +36,7 @@ export function NewsroomAssignmentDetail({ assignment, deskOptions = [], onDeskC
|
|
|
36
36
|
const changeDesk = (value: string) => {
|
|
37
37
|
if (!onDeskChange) return;
|
|
38
38
|
setSavingDesk(true); setDeskError(null);
|
|
39
|
-
onDeskChange(value === NO_DESK_VALUE ? null : value).catch((reason: unknown) => setDeskError(reason instanceof Error ? reason.message : "Could not save the
|
|
39
|
+
onDeskChange(value === NO_DESK_VALUE ? null : value).catch((reason: unknown) => setDeskError(reason instanceof Error ? reason.message : "Could not save the section.")).finally(() => setSavingDesk(false));
|
|
40
40
|
};
|
|
41
41
|
const facts: DetailField[] = [
|
|
42
42
|
{ label: "Status", value: assignment.status },
|
|
@@ -58,14 +58,14 @@ export function NewsroomAssignmentDetail({ assignment, deskOptions = [], onDeskC
|
|
|
58
58
|
{facts.map(field => <div key={field.label}><dt>{field.label}</dt><dd>{field.value}</dd></div>)}
|
|
59
59
|
</dl>
|
|
60
60
|
{onDeskChange && <label className="newsroom-assignment-detail-desk">
|
|
61
|
-
<span>
|
|
61
|
+
<span>Section</span>
|
|
62
62
|
<Select onValueChange={(value) => { if (typeof value === "string") changeDesk(value); }} value={deskChoices.some(option => option.value === currentDesk) ? currentDesk : NO_DESK_VALUE} disabled={savingDesk}>
|
|
63
|
-
<SelectTrigger aria-label="
|
|
64
|
-
<SelectValue>{deskChoices.find(option => option.value === currentDesk)?.label ?? "No
|
|
63
|
+
<SelectTrigger aria-label="Section" className="w-full" data-newsroom-assignment-desk>
|
|
64
|
+
<SelectValue>{deskChoices.find(option => option.value === currentDesk)?.label ?? "No section"}</SelectValue>
|
|
65
65
|
</SelectTrigger>
|
|
66
66
|
<SelectContent>
|
|
67
|
-
<SelectItem value={NO_DESK_VALUE}>No
|
|
68
|
-
{(["core", "rotating"] as const).map(kind => deskChoices.some(option => option.kind === kind) ? <SelectGroup key={kind}><SelectLabel>{kind === "core" ? "Core
|
|
67
|
+
<SelectItem value={NO_DESK_VALUE}>No section</SelectItem>
|
|
68
|
+
{(["core", "rotating"] as const).map(kind => deskChoices.some(option => option.kind === kind) ? <SelectGroup key={kind}><SelectLabel>{kind === "core" ? "Core sections" : "Rotating sections"}</SelectLabel>{deskChoices.filter(option => option.kind === kind).map(option => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup> : null)}
|
|
69
69
|
</SelectContent>
|
|
70
70
|
</Select>
|
|
71
71
|
{currentDesk && !deskChoices.some(option => option.value === currentDesk) ? <small className="newsroom-desk-legacy-value">Old section value: {currentDesk}</small> : null}
|
|
@@ -76,7 +76,7 @@ export function NewsroomOpsOverview({
|
|
|
76
76
|
data-newsroom-ops-home="true"
|
|
77
77
|
>
|
|
78
78
|
<div className="space-y-8" data-newsroom-overview-feeds>
|
|
79
|
-
<div aria-label="Notification Center feed" className="newsroom-overview-feed-frame
|
|
79
|
+
<div aria-label="Notification Center feed" className="newsroom-overview-feed-frame rounded-xl border bg-card p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-newsroom-overview-notifications role="region" tabIndex={0}>
|
|
80
80
|
<NotificationCenter
|
|
81
81
|
actorLabel={actorLabel}
|
|
82
82
|
decisions={decisions}
|
|
@@ -145,9 +145,9 @@ export function NewsroomOpsOverview({
|
|
|
145
145
|
</section>
|
|
146
146
|
|
|
147
147
|
{desks.length ? (
|
|
148
|
-
<section aria-label="
|
|
149
|
-
<h2 className="text-sm font-medium text-muted-foreground">
|
|
150
|
-
{coreDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="core">Core
|
|
148
|
+
<section aria-label="Sections" className="space-y-3" data-newsroom-ops-desks>
|
|
149
|
+
<h2 className="text-sm font-medium text-muted-foreground">Sections</h2>
|
|
150
|
+
{coreDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="core">Core sections</h3> : null}
|
|
151
151
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
152
152
|
{coreDesks.map((section) => (
|
|
153
153
|
<Link
|
|
@@ -167,7 +167,7 @@ export function NewsroomOpsOverview({
|
|
|
167
167
|
</Link>
|
|
168
168
|
))}
|
|
169
169
|
</div>
|
|
170
|
-
{rotatingDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="rotating">Rotating
|
|
170
|
+
{rotatingDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="rotating">Rotating sections <small>appear for a while, then rotate out</small></h3> : null}
|
|
171
171
|
{rotatingDesks.length ? <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
172
172
|
{rotatingDesks.map((section) => (
|
|
173
173
|
<Link
|
|
@@ -23,8 +23,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
23
23
|
.organizer-sidebar-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.organizer-sidebar-head .organizer-brand{flex:1;min-width:0}
|
|
24
24
|
.organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
|
|
25
25
|
.organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
|
|
26
|
-
.newsroom-overview-feed-frame{height:
|
|
27
|
-
@media(min-width:640px){.newsroom-overview-feed-frame{height:38rem}}
|
|
26
|
+
.newsroom-overview-feed-frame{height:auto}
|
|
27
|
+
@media(min-width:640px){.newsroom-overview-feed-frame{height:38rem;overflow-y:auto}}
|
|
28
28
|
.organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
|
|
29
29
|
.organizer-account{display:flex;align-items:center;gap:10px;min-height:44px;flex-shrink:0;border:1px solid var(--org-line);padding:8px;text-align:left;border-radius:3px}
|
|
30
30
|
.organizer-account span{min-width:0}.organizer-account strong{display:block;font:600 11px var(--kb-theme-ui-font);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organizer-account small{display:block;font-size:9px;color:var(--org-muted);margin-top:2px}
|
|
@@ -7,7 +7,7 @@ import { Button } from "./ui/button";
|
|
|
7
7
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
8
8
|
import {
|
|
9
9
|
cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
|
|
10
|
-
steeringText, subjectHref, summarize, summaryText,
|
|
10
|
+
steeringText, subjectHref, subjectLabel, summarize, summaryText,
|
|
11
11
|
type AnswerInput, type CyclotronEntry, type ReviewRequest,
|
|
12
12
|
} from "../lib/review-requests";
|
|
13
13
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
@@ -22,6 +22,7 @@ type ViewProps = {
|
|
|
22
22
|
readIds: ReadonlySet<string>;
|
|
23
23
|
held: CyclotronEntry[];
|
|
24
24
|
autoDecided: CyclotronEntry[];
|
|
25
|
+
storyTitles?: Readonly<Record<string, string>>;
|
|
25
26
|
demo?: boolean;
|
|
26
27
|
busyId?: string | null;
|
|
27
28
|
error?: string | null;
|
|
@@ -30,8 +31,8 @@ type ViewProps = {
|
|
|
30
31
|
onMarkAllRead?: () => void;
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
|
|
34
|
-
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean;
|
|
34
|
+
function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
35
|
+
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>;
|
|
35
36
|
onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
|
|
36
37
|
}) {
|
|
37
38
|
const [note, setNote] = useState("");
|
|
@@ -48,7 +49,7 @@ function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
|
|
|
48
49
|
</div>
|
|
49
50
|
<p className="text-xs text-muted-foreground">
|
|
50
51
|
Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
|
|
51
|
-
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{request.subject
|
|
52
|
+
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles)}</Link> : <span>{subjectLabel(request.subject, titles)}</span>}
|
|
52
53
|
</p>
|
|
53
54
|
{request.ask ? <p className="text-sm">{request.ask}</p> : null}
|
|
54
55
|
{request.links.length ? (
|
|
@@ -124,12 +125,12 @@ function Section({ id, title, count, children, empty }: { id: string; title: str
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
|
|
127
|
-
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
128
|
+
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
128
129
|
const sections = useMemo(() => sectionReviews(requests), [requests]);
|
|
129
130
|
const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
|
|
130
131
|
const summary = summarize(sections, held, autoDecided, unreadCount);
|
|
131
132
|
const entry = (request: ReviewRequest) => (
|
|
132
|
-
<Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
133
|
+
<Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
133
134
|
);
|
|
134
135
|
if (loadState === "loading") return (
|
|
135
136
|
<section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
|
|
@@ -204,6 +205,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
204
205
|
const [userKey, setUserKey] = useState<string | null>(null);
|
|
205
206
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
206
207
|
const [error, setError] = useState<string | null>(null);
|
|
208
|
+
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
207
209
|
|
|
208
210
|
const refresh = useCallback(async () => {
|
|
209
211
|
if (demo) {
|
|
@@ -223,6 +225,8 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
223
225
|
setReadIds(new Set(loaded.readIds));
|
|
224
226
|
setUserKey(loaded.userKey);
|
|
225
227
|
setLoadState("ready");
|
|
228
|
+
const { loadStoryTitles } = await import("../lib/review-client");
|
|
229
|
+
setStoryTitles(await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({})));
|
|
226
230
|
} catch {
|
|
227
231
|
setLoadState("unavailable");
|
|
228
232
|
}
|
|
@@ -266,7 +270,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
266
270
|
|
|
267
271
|
return (
|
|
268
272
|
<NotificationCenterView
|
|
269
|
-
autoDecided={autoDecided} busyId={busyId} demo={demo} error={error} held={held} loadState={loadState}
|
|
273
|
+
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} demo={demo} error={error} held={held} loadState={loadState}
|
|
270
274
|
onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
|
|
271
275
|
/>
|
|
272
276
|
);
|
|
@@ -14,7 +14,9 @@ export type AutomationUsage = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export type AutomationRun = {
|
|
17
|
-
origin: "monitor" | "manual";
|
|
17
|
+
origin: "monitor" | "manual" | "stage";
|
|
18
|
+
/** The stage a story entered to start this run (origin "stage"). */
|
|
19
|
+
stage?: string | null;
|
|
18
20
|
requestedBy: string | null;
|
|
19
21
|
reason: string | null;
|
|
20
22
|
sourceId: string | null;
|
|
@@ -28,6 +30,10 @@ export type AutomationRun = {
|
|
|
28
30
|
error: string | null;
|
|
29
31
|
runRecord: "found" | "missing" | "unavailable" | "none";
|
|
30
32
|
storyId: string | null;
|
|
33
|
+
/** False when no version of the story remains (it was deleted); null when this was not looked up. */
|
|
34
|
+
storyExists?: boolean | null;
|
|
35
|
+
/** Tool name to number of calls the procedure made, when the run recorded them. */
|
|
36
|
+
toolsUsed?: Record<string, number> | null;
|
|
31
37
|
storyVersionId: string | null;
|
|
32
38
|
storyTitle: string | null;
|
|
33
39
|
costUsd: number | null;
|
|
@@ -164,7 +170,13 @@ export function costText(run: Pick<AutomationRun, "costUsd" | "costNote" | "over
|
|
|
164
170
|
return run.overBudget && run.budgetUsd != null ? `${base}. Over the budget of ${dollars(run.budgetUsd)} per run` : base;
|
|
165
171
|
}
|
|
166
172
|
|
|
167
|
-
export function
|
|
173
|
+
export function toolsText(run: Pick<AutomationRun, "toolsUsed">): string {
|
|
174
|
+
const tools = Object.entries(run.toolsUsed ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
175
|
+
return tools.length ? `Tools used: ${tools.map(([name, count]) => `${name} x${count}`).join(", ")}` : "Tools used: none recorded";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function whoText(run: Pick<AutomationRun, "origin" | "requestedBy" | "sourceId"> & { stage?: string | null }): string {
|
|
179
|
+
if (run.origin === "stage") return `Story entered ${run.stage ?? "a stage"}`;
|
|
168
180
|
return run.origin === "manual" ? `Run now by ${run.requestedBy ?? "someone"}` : `Item: ${run.sourceId ?? "unknown"}`;
|
|
169
181
|
}
|
|
170
182
|
|
package/lib/review-client.ts
CHANGED
|
@@ -97,3 +97,21 @@ export async function markReviewRead(userKey: string, requestId: string, now: Da
|
|
|
97
97
|
await run(`mutation Mark($input: UpdateReviewReadMarkInput!) { updateReviewReadMark(input: $input) { id } }`, { input });
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
const MAX_TITLE_LOOKUPS = 40;
|
|
102
|
+
|
|
103
|
+
/** Titles of the stories that review requests are about, by lineage id. A story that cannot be read simply has no entry (the card falls back to the id). */
|
|
104
|
+
export async function loadStoryTitles(ids: string[]): Promise<Record<string, string>> {
|
|
105
|
+
const titles: Record<string, string> = {};
|
|
106
|
+
await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
|
|
107
|
+
const result = await run(
|
|
108
|
+
`query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
|
|
109
|
+
{ lineageId: id },
|
|
110
|
+
);
|
|
111
|
+
const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
|
|
112
|
+
const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
|
|
113
|
+
const title = newest?.headline || newest?.title;
|
|
114
|
+
if (title) titles[id] = title;
|
|
115
|
+
}));
|
|
116
|
+
return titles;
|
|
117
|
+
}
|
package/lib/review-requests.ts
CHANGED
|
@@ -145,6 +145,12 @@ export function steeringText(request: ReviewRequest): string | null {
|
|
|
145
145
|
return `You steered ${said}. ${request.raisedBy} has not used it yet; it will on its next run.`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** What a request is about, in words: a story shows its title (the id when the title is not known), anything else its type and id. */
|
|
149
|
+
export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}): string {
|
|
150
|
+
if (subject.type === "story") return titles[subject.id] ?? `story ${subject.id}`;
|
|
151
|
+
return `${subject.type.replace("_", " ")} ${subject.id}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
148
154
|
export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
|
|
149
155
|
const id = encodeURIComponent(subject.id);
|
|
150
156
|
if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED