mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): collapse chat-shell system notices to humanized one-line rows
System comments in the experimental chat shell now render as quiet centered one-liners (tone icon + plain-English title + relative time + chevron) that expand on click to the full markdown body and structured metadata sections, instead of large gray paragraphs of raw recovery text. - Thread presentation/metadata/createdAtIso through TaskChatMessageItem and the comment adapter (system comments only). - Fix authorKind ordering: authorType system now wins over a derivable run->agent linkage, so recovery notices no longer render as agent bubbles. - New pure classifier system-notice-humanizer.ts covering the recovery families (claude_auth_required, configuration_incomplete, workspace validation, no-live-execution-path with recovery-owner extraction) plus a System update fallback with truncated first sentence. - New TaskChatSystemNotice component; expanded state reuses the SystemNotice metadata-section rows via a newly extracted SystemNoticeMetadataSections. - Collapse is presentation-only; respects presentation.detailsDefaultOpen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0a31ccf7c
commit
e907db5926
9 files changed
+518
-23
No files matched your search
@@ -206,6 +206,38 @@ function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTo
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The structured metadata rows shared by the boxed SystemNotice details panel
|
||||
* and the chat shell's expandable system row (TaskChatSystemNotice, PAP-443).
|
||||
*/
|
||||
export function SystemNoticeMetadataSections({
|
||||
sections,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
sections: SystemNoticeMetadataSection[];
|
||||
tone?: SystemNoticeTone;
|
||||
}) {
|
||||
const tokens = TONE_TOKENS[tone];
|
||||
return (
|
||||
<div className="divide-y divide-border/50 px-1 py-1">
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
|
||||
{section.title ? (
|
||||
<div className="px-3 pb-1 pt-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
|
||||
{section.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{section.rows.map((row, rowIdx) => (
|
||||
<MetadataRow key={rowIdx} row={row} tone={tokens} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemNotice({
|
||||
tone = "neutral",
|
||||
label,
|
||||
@@ -312,22 +344,7 @@ export function SystemNotice({
|
||||
tokens.divider,
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-border/50 px-1 py-1">
|
||||
{metadata!.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
|
||||
{section.title ? (
|
||||
<div className="px-3 pb-1 pt-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
|
||||
{section.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{section.rows.map((row, rowIdx) => (
|
||||
<MetadataRow key={rowIdx} row={row} tone={tokens} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SystemNoticeMetadataSections sections={metadata!} tone={tone} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AttachmentTrigger,
|
||||
} from "@/components/ui/attachment";
|
||||
import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments";
|
||||
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
interface TaskChatBubbleProps {
|
||||
@@ -50,11 +51,8 @@ export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) {
|
||||
}
|
||||
|
||||
if (item.author === "system") {
|
||||
return (
|
||||
<div className="tc-enter-bubble flex justify-center py-1">
|
||||
<p className="max-w-(--pct-85) text-center text-xs text-muted-foreground">{item.text}</p>
|
||||
</div>
|
||||
);
|
||||
// Collapsed humanized one-liner, expandable to the full detail (PAP-443).
|
||||
return <TaskChatSystemNotice item={item} />;
|
||||
}
|
||||
|
||||
const isHuman = item.author === "human";
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
describe("TaskChatSystemNotice (PAP-443)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const recoveryBody =
|
||||
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
|
||||
"Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.";
|
||||
|
||||
function renderNotice(overrides: Partial<TaskChatMessageItem> = {}) {
|
||||
const item: TaskChatMessageItem = {
|
||||
id: "sys-1",
|
||||
kind: "message",
|
||||
author: "system",
|
||||
text: recoveryBody,
|
||||
createdAtIso: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
flushSync(() =>
|
||||
root!.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatSystemNotice item={item} />
|
||||
</ThemeProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleButton() {
|
||||
return container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-system-notice"] button',
|
||||
)!;
|
||||
}
|
||||
|
||||
it("collapses to a humanized one-liner with relative time and hides the raw body", () => {
|
||||
renderNotice();
|
||||
const button = toggleButton();
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(button.textContent).toContain("Task paused — a secret/config binding is missing");
|
||||
expect(button.textContent).toContain("5m ago");
|
||||
expect(container.textContent).not.toContain("source-scoped recovery action");
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("expands on click to the full markdown body and metadata sections", () => {
|
||||
renderNotice({
|
||||
metadata: {
|
||||
version: 1,
|
||||
sections: [
|
||||
{ title: "Failure", rows: [{ type: "code", label: "Code", code: "configuration_incomplete" }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
flushSync(() => toggleButton().click());
|
||||
|
||||
expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
|
||||
const details = container.querySelector('[data-testid="task-chat-system-notice-details"]');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details!.textContent).toContain("required secret/env bindings are missing");
|
||||
expect(details!.textContent).toContain("Failure");
|
||||
expect(details!.textContent).toContain("configuration_incomplete");
|
||||
|
||||
// Collapses back — presentation-only fold, nothing lost.
|
||||
flushSync(() => toggleButton().click());
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("respects presentation.detailsDefaultOpen", () => {
|
||||
renderNotice({
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Run recovery",
|
||||
detailsDefaultOpen: true,
|
||||
},
|
||||
});
|
||||
expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
|
||||
expect(toggleButton().textContent).toContain("Run recovery");
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useId, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
CircleCheck,
|
||||
Info,
|
||||
OctagonAlert,
|
||||
TriangleAlert,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import {
|
||||
SystemNoticeMetadataSections,
|
||||
type SystemNoticeTone,
|
||||
} from "@/components/SystemNotice";
|
||||
import { humanizeSystemNotice } from "@/lib/system-notice-humanizer";
|
||||
import { mapCommentMetadataToSystemNoticeSections } from "@/lib/system-notice-comment";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
const TONE_ICON: Record<SystemNoticeTone, LucideIcon> = {
|
||||
neutral: Info,
|
||||
info: Info,
|
||||
success: CircleCheck,
|
||||
warning: TriangleAlert,
|
||||
danger: OctagonAlert,
|
||||
};
|
||||
|
||||
const TONE_ICON_CLASS: Record<SystemNoticeTone, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
info: "text-sky-600 dark:text-sky-400",
|
||||
success: "text-emerald-600 dark:text-emerald-400",
|
||||
warning: "text-amber-600 dark:text-amber-400",
|
||||
danger: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
|
||||
/**
|
||||
* System comment row in the chat shell (PAP-443 / PAP-442 Phase 1): collapsed,
|
||||
* it reads as a quiet centered one-liner in TaskChatMarker's register — tone
|
||||
* icon + humanized plain-English title + relative time + chevron — instead of
|
||||
* a large gray paragraph of raw text. Expanding reveals the full
|
||||
* markdown-rendered body plus any structured metadata sections; nothing is
|
||||
* suppressed, only folded.
|
||||
*/
|
||||
export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) {
|
||||
const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen));
|
||||
const detailsId = useId();
|
||||
const { title, tone, detail } = humanizeSystemNotice({
|
||||
body: item.text,
|
||||
presentation: item.presentation,
|
||||
});
|
||||
const sections = mapCommentMetadataToSystemNoticeSections(item.metadata);
|
||||
const ToneIcon = TONE_ICON[tone];
|
||||
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
|
||||
|
||||
return (
|
||||
<div className="tc-enter-bubble flex flex-col items-center py-1" data-testid="task-chat-system-notice">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={detailsId}
|
||||
className={cn(
|
||||
"flex max-w-(--pct-85) items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground",
|
||||
"hover:bg-muted/50 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
)}
|
||||
>
|
||||
<ToneIcon className={cn("h-3.5 w-3.5 shrink-0", TONE_ICON_CLASS[tone])} aria-hidden />
|
||||
<span className="truncate font-medium">{title}</span>
|
||||
{detail ? <span className="hidden truncate sm:inline">· {detail}</span> : null}
|
||||
{relative ? <span className="shrink-0 text-muted-foreground/70">· {relative}</span> : null}
|
||||
<ChevronDown
|
||||
className={cn("h-3.5 w-3.5 shrink-0 transition-transform duration-150", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id={detailsId}
|
||||
data-testid="task-chat-system-notice-details"
|
||||
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
|
||||
>
|
||||
<div className="px-3 py-2.5 text-foreground/90">
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
{sections.length > 0 ? (
|
||||
<div className="border-t border-border/70 bg-background/50 dark:bg-background/30">
|
||||
<SystemNoticeMetadataSections sections={sections} tone={tone} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,4 +22,64 @@ describe("commentsToTaskChatItems", () => {
|
||||
expect(item.author).toBe("agent");
|
||||
expect(item.interstitial).toBeUndefined();
|
||||
});
|
||||
|
||||
it("classifies system comments as system even with a derivable run→agent linkage (PAP-443)", () => {
|
||||
const comments = [
|
||||
{
|
||||
id: "c-sys",
|
||||
body: "Paperclip automatically retried dispatch, but it still has no live execution path.",
|
||||
authorType: "system",
|
||||
authorAgentId: null,
|
||||
derivedAuthorAgentId: "agent-1",
|
||||
createdAt: "2026-08-07T09:00:00.000Z",
|
||||
} as unknown as IssueChatComment,
|
||||
];
|
||||
const items = commentsToTaskChatItems(comments);
|
||||
expect(items).toHaveLength(1);
|
||||
const item = items[0];
|
||||
if (item.kind !== "message") throw new Error("expected message item");
|
||||
expect(item.author).toBe("system");
|
||||
});
|
||||
|
||||
it("carries presentation, metadata, and the raw timestamp for system comments", () => {
|
||||
const presentation = {
|
||||
kind: "system_notice",
|
||||
tone: "warning",
|
||||
title: "Run recovery",
|
||||
detailsDefaultOpen: true,
|
||||
};
|
||||
const metadata = {
|
||||
version: 1,
|
||||
sections: [{ rows: [{ type: "text", label: "Reason", text: "quota" }] }],
|
||||
};
|
||||
const comments = [
|
||||
{
|
||||
id: "c-sys",
|
||||
body: "Recovery notice.",
|
||||
authorType: "system",
|
||||
presentation,
|
||||
metadata,
|
||||
createdAt: new Date("2026-08-07T09:00:00.000Z"),
|
||||
} as unknown as IssueChatComment,
|
||||
{
|
||||
id: "c-agent",
|
||||
body: "Agent reply.",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
presentation: null,
|
||||
metadata,
|
||||
createdAt: "2026-08-07T09:01:00.000Z",
|
||||
} as unknown as IssueChatComment,
|
||||
];
|
||||
const items = commentsToTaskChatItems(comments);
|
||||
const [sys, agent] = items;
|
||||
if (sys.kind !== "message" || agent.kind !== "message") throw new Error("expected messages");
|
||||
expect(sys.presentation).toEqual(presentation);
|
||||
expect(sys.metadata).toEqual(metadata);
|
||||
expect(sys.createdAtIso).toBe("2026-08-07T09:00:00.000Z");
|
||||
// Non-system authors keep the item lean — no structured notice fields.
|
||||
expect(agent.presentation).toBeUndefined();
|
||||
expect(agent.metadata).toBeUndefined();
|
||||
expect(agent.createdAtIso).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -28,10 +28,13 @@ function effectiveAgentId(comment: IssueChatComment): string | null {
|
||||
}
|
||||
|
||||
function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
|
||||
// System authorship wins over any derivable run→agent linkage (PAP-443):
|
||||
// recovery notices carry a derivedAuthorAgentId but must not render as
|
||||
// agent bubbles.
|
||||
if (comment.authorType === "system") return "system";
|
||||
if (effectiveAgentId(comment)) return "agent";
|
||||
if (comment.authorType === "user") return "human";
|
||||
if (comment.authorType === "agent") return "agent";
|
||||
return "system";
|
||||
return "agent";
|
||||
}
|
||||
|
||||
/** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */
|
||||
@@ -66,6 +69,12 @@ export function commentsToTaskChatItems(
|
||||
: comment.clientStatus === "pending"
|
||||
? "pending"
|
||||
: undefined;
|
||||
const createdAtIso =
|
||||
comment.createdAt instanceof Date
|
||||
? comment.createdAt.toISOString()
|
||||
: comment.createdAt
|
||||
? String(comment.createdAt)
|
||||
: undefined;
|
||||
items.push({
|
||||
id: comment.id || comment.clientId || `${comment.createdAt}`,
|
||||
kind: "message",
|
||||
@@ -76,6 +85,11 @@ export function commentsToTaskChatItems(
|
||||
optimistic,
|
||||
agentIcon,
|
||||
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
|
||||
// System notices carry their structured hints through to the render
|
||||
// layer (PAP-443); other authors keep the item lean.
|
||||
presentation: kind === "system" ? comment.presentation ?? null : undefined,
|
||||
metadata: kind === "system" ? comment.metadata ?? null : undefined,
|
||||
createdAtIso: kind === "system" ? createdAtIso : undefined,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* inventory for the mapping. No timing/motion values live here — those are
|
||||
* CSS motion tokens in ui/src/index.css.
|
||||
*/
|
||||
import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
|
||||
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
|
||||
|
||||
/** Who authored a thread row — the primary legibility signal. */
|
||||
@@ -94,6 +95,15 @@ export interface TaskChatMessageItem {
|
||||
* Expanding still nests the tool history beneath the bubble.
|
||||
*/
|
||||
attachedTurn?: TaskChatTurnItem;
|
||||
/**
|
||||
* Structured system-notice fields (PAP-443), carried only for
|
||||
* author === "system": the comment's server-authored presentation hints and
|
||||
* metadata sections drive the collapsed one-line row + expandable detail.
|
||||
*/
|
||||
presentation?: IssueCommentPresentation | null;
|
||||
metadata?: IssueCommentMetadata | null;
|
||||
/** Raw comment timestamp (ISO) — the collapsed system row shows relative time. */
|
||||
createdAtIso?: string;
|
||||
}
|
||||
|
||||
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { humanizeSystemNotice } from "./system-notice-humanizer";
|
||||
|
||||
describe("humanizeSystemNotice", () => {
|
||||
it("uses presentation title and tone when present", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body: "Paperclip automatically retried dispatch but it still has no live execution path.",
|
||||
presentation: {
|
||||
kind: "system_notice",
|
||||
tone: "danger",
|
||||
title: "Run recovery exhausted",
|
||||
detailsDefaultOpen: false,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ title: "Run recovery exhausted", tone: "danger" });
|
||||
});
|
||||
|
||||
it("classifies claude_auth_required retry failures", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body:
|
||||
"Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run " +
|
||||
"recovery, but it still has no live execution path. Latest retry failure: `claude_auth_required` - adapter " +
|
||||
"handshake failed. Moving it to `blocked` so it is visible for intervention.",
|
||||
});
|
||||
expect(result.title).toBe("Task paused — Claude needs re-authentication");
|
||||
expect(result.tone).toBe("warning");
|
||||
});
|
||||
|
||||
it("classifies configuration_incomplete failures", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body:
|
||||
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
|
||||
"Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.",
|
||||
});
|
||||
expect(result.title).toBe("Task paused — a secret/config binding is missing");
|
||||
expect(result.tone).toBe("warning");
|
||||
});
|
||||
|
||||
it("classifies missing secret bindings even without a failure code", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body:
|
||||
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
|
||||
"Moving it to `blocked` so an operator can bind the missing secret(s).",
|
||||
});
|
||||
expect(result.title).toBe("Task paused — a secret/config binding is missing");
|
||||
});
|
||||
|
||||
it("classifies workspace validation failures", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body:
|
||||
"Paperclip stopped before launching the local adapter because the issue workspace failed validation. " +
|
||||
"This prevents git-sensitive adapters from running in an unrelated fallback cwd.",
|
||||
});
|
||||
expect(result.title).toBe("Task paused — workspace problem");
|
||||
expect(result.tone).toBe("warning");
|
||||
});
|
||||
|
||||
it("names the recovery owner from the markdown link for generic no-live-execution-path notices", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body:
|
||||
"Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " +
|
||||
"but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.\n\n" +
|
||||
"- Recovery action: `ra-123`\n" +
|
||||
"- Recovery owner: [Dana the Manager](/PAP/agents/agent-9)\n" +
|
||||
"- Next action: the recovery owner should restore a live execution path.",
|
||||
});
|
||||
expect(result.title).toBe("Task paused — waiting on Dana the Manager");
|
||||
expect(result.tone).toBe("warning");
|
||||
});
|
||||
|
||||
it("still humanizes no-live-execution-path notices without an owner link", () => {
|
||||
const result = humanizeSystemNotice({
|
||||
body:
|
||||
"Paperclip automatically retried dispatch, but it still has no live execution path. " +
|
||||
"Moving it to `blocked` so it is visible for intervention.",
|
||||
});
|
||||
expect(result.title).toBe("Task paused — waiting on a recovery owner");
|
||||
});
|
||||
|
||||
it("falls back to System update plus a truncated first sentence", () => {
|
||||
const longFirstSentence =
|
||||
"The heartbeat scheduler observed an unusual condition while reconciling this issue's run ledger against the " +
|
||||
"control plane state and recorded the discrepancy for later review.";
|
||||
const result = humanizeSystemNotice({ body: `${longFirstSentence} Second sentence here.` });
|
||||
expect(result.title).toBe("System update");
|
||||
expect(result.tone).toBe("neutral");
|
||||
expect(result.detail).toBeDefined();
|
||||
expect(result.detail!.length).toBeLessThanOrEqual(80);
|
||||
expect(result.detail!.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a short first sentence untruncated in the fallback detail", () => {
|
||||
const result = humanizeSystemNotice({ body: "Run rescheduled. More context follows." });
|
||||
expect(result).toEqual({
|
||||
title: "System update",
|
||||
tone: "neutral",
|
||||
detail: "Run rescheduled.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { IssueCommentPresentation } from "@paperclipai/shared";
|
||||
import type { SystemNoticeTone } from "../components/SystemNotice";
|
||||
|
||||
/**
|
||||
* Pure classifier for system comments in the chat shell (PAP-443 / PAP-442
|
||||
* Phase 1): derive a short plain-English one-liner + tone for the collapsed
|
||||
* row. Server-authored presentation hints win when present; otherwise the
|
||||
* known recovery families are pattern-matched from the comment body. Nothing
|
||||
* here suppresses data — the full body stays available behind the expand.
|
||||
*/
|
||||
export interface HumanizedSystemNotice {
|
||||
title: string;
|
||||
tone: SystemNoticeTone;
|
||||
/** Optional muted trailing snippet for the generic fallback row. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_TITLE = "System update";
|
||||
const DETAIL_MAX_CHARS = 80;
|
||||
|
||||
/** Failure code the recovery comments embed as "Latest retry failure: `code`". */
|
||||
function retryFailureCode(body: string): string | null {
|
||||
const match = body.match(/Latest retry failure: `([^`]+)`/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Owner display name from a "Recovery owner: [Name](/…/agents/…)" markdown link. */
|
||||
function recoveryOwnerName(body: string): string | null {
|
||||
const match = body.match(/Recovery owner: \[([^\]]+)\]\(/);
|
||||
const name = match?.[1]?.trim();
|
||||
return name && name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
function firstSentence(body: string): string | undefined {
|
||||
const firstLine = body
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
if (!firstLine) return undefined;
|
||||
const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
|
||||
const sentence = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine;
|
||||
if (sentence.length <= DETAIL_MAX_CHARS) return sentence;
|
||||
return `${sentence.slice(0, DETAIL_MAX_CHARS - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function humanizeSystemNotice(input: {
|
||||
body: string;
|
||||
presentation?: IssueCommentPresentation | null;
|
||||
}): HumanizedSystemNotice {
|
||||
const presentationTitle = input.presentation?.title?.trim();
|
||||
const presentationTone = input.presentation?.tone;
|
||||
if (presentationTitle) {
|
||||
return { title: presentationTitle, tone: presentationTone ?? "neutral" };
|
||||
}
|
||||
|
||||
const body = input.body ?? "";
|
||||
const code = retryFailureCode(body);
|
||||
|
||||
if (code === "claude_auth_required") {
|
||||
return {
|
||||
title: "Task paused — Claude needs re-authentication",
|
||||
tone: presentationTone ?? "warning",
|
||||
};
|
||||
}
|
||||
if (code === "configuration_incomplete" || body.includes("secret/env bindings are missing")) {
|
||||
return {
|
||||
title: "Task paused — a secret/config binding is missing",
|
||||
tone: presentationTone ?? "warning",
|
||||
};
|
||||
}
|
||||
if (code === "workspace_validation_failed" || body.includes("workspace failed validation")) {
|
||||
return {
|
||||
title: "Task paused — workspace problem",
|
||||
tone: presentationTone ?? "warning",
|
||||
};
|
||||
}
|
||||
if (body.includes("no live execution path")) {
|
||||
const owner = recoveryOwnerName(body);
|
||||
return {
|
||||
title: owner
|
||||
? `Task paused — waiting on ${owner}`
|
||||
: "Task paused — waiting on a recovery owner",
|
||||
tone: presentationTone ?? "warning",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: FALLBACK_TITLE,
|
||||
tone: presentationTone ?? "neutral",
|
||||
detail: firstSentence(body),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user