chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Block Kit parity tests for the JSX render components. Each component is a
|
||||
* `@copilotkit/channels-ui` `ComponentFn`; we assert the full
|
||||
* `renderSlackMessage(renderToIR(<… />))` output — both the `blocks` and the
|
||||
* attachment `accent` — against the legacy `defineSlackComponent` shapes.
|
||||
*
|
||||
* The shared IR→mrkdwn path runs section/field/context text through
|
||||
* `markdownToMrkdwn`, so the components author Markdown bold (`**x**`) which
|
||||
* the transform rewrites into Slack bold (`*x*`). The block structure,
|
||||
* ordering, emoji, dividers, footers and accent colors match the legacy
|
||||
* `.ts` output, and the link/label forms below assert the Slack-bold `*…*`
|
||||
* the old `defineSlackComponent` code produced.
|
||||
*
|
||||
* Status/priority glyphs are now platform-neutral unicode (✅ 🔵 🚨 🔴 etc.)
|
||||
* so they render identically on both Slack and Telegram.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToIR } from "@copilotkit/channels-ui";
|
||||
import { renderSlackMessage } from "@copilotkit/channels-slack";
|
||||
import { renderTelegram } from "@copilotkit/channels-telegram";
|
||||
import { IssueList } from "../issue-list.js";
|
||||
import { IssueCard } from "../issue-card.js";
|
||||
import { PageList } from "../page-list.js";
|
||||
|
||||
describe("IssueList component", () => {
|
||||
it("renders exactly three blocks: header, a single section with one line per issue, and a count footer", () => {
|
||||
const { blocks, accent } = renderSlackMessage(
|
||||
renderToIR(
|
||||
<IssueList
|
||||
heading="Open"
|
||||
issues={[
|
||||
{
|
||||
identifier: "CPK-101",
|
||||
title: "Checkout 500s under load",
|
||||
url: "https://linear.app/copilotkit/issue/CPK-101",
|
||||
state: "In Progress",
|
||||
assignee: "Alem",
|
||||
priority: "Urgent",
|
||||
updated: "2d ago",
|
||||
},
|
||||
{
|
||||
identifier: "CPK-102",
|
||||
title: "Login redirect loop",
|
||||
url: "https://linear.app/copilotkit/issue/CPK-102",
|
||||
state: "Todo",
|
||||
assignee: "Sam",
|
||||
priority: "High",
|
||||
updated: "5h ago",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
// Fixed three-block layout regardless of issue count.
|
||||
expect(blocks).toHaveLength(3);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: "📋 Open" },
|
||||
});
|
||||
expect(blocks[1]).toMatchObject({ type: "section" });
|
||||
expect(blocks[2]).toMatchObject({ type: "context" });
|
||||
|
||||
const section = blocks[1] as { text: { type: string; text: string } };
|
||||
expect(section.text.type).toBe("mrkdwn");
|
||||
const text = section.text.text;
|
||||
// One line per issue, joined by newlines.
|
||||
expect(text.split("\n")).toHaveLength(2);
|
||||
// Each issue is a linked, bold identifier (Markdown bold → Slack bold).
|
||||
expect(text).toContain(
|
||||
"<https://linear.app/copilotkit/issue/CPK-101|*CPK-101*>",
|
||||
);
|
||||
expect(text).toContain(
|
||||
"<https://linear.app/copilotkit/issue/CPK-102|*CPK-102*>",
|
||||
);
|
||||
// Titles, assignees and updated meta are inline on the line.
|
||||
expect(text).toContain("Checkout 500s under load");
|
||||
expect(text).toContain("Login redirect loop");
|
||||
expect(text).toContain("Alem");
|
||||
expect(text).toContain("Sam");
|
||||
expect(text).toContain("2d ago");
|
||||
// In-progress maps to the blue dot.
|
||||
expect(text).toContain("🔵");
|
||||
// Count footer.
|
||||
expect(JSON.stringify(blocks[2])).toContain("2 issues");
|
||||
// Hottest priority (Urgent) drives the accent.
|
||||
expect(accent).toBe("#EB5757");
|
||||
});
|
||||
|
||||
it("caps the section at 15 lines and reports the overflow in the footer", () => {
|
||||
const issues = Array.from({ length: 20 }, (_, i) => ({
|
||||
identifier: `CPK-${i + 1}`,
|
||||
title: `Issue ${i + 1}`,
|
||||
}));
|
||||
const { blocks } = renderSlackMessage(
|
||||
renderToIR(<IssueList heading="Many" issues={issues} />),
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(3);
|
||||
const section = blocks[1] as { text: { text: string } };
|
||||
// Only the first 15 issues are rendered.
|
||||
expect(section.text.text.split("\n")).toHaveLength(15);
|
||||
expect(section.text.text).toContain("*CPK-1*");
|
||||
expect(section.text.text).toContain("*CPK-15*");
|
||||
expect(section.text.text).not.toContain("*CPK-16*");
|
||||
// Footer surfaces the overflow.
|
||||
expect(JSON.stringify(blocks[2])).toContain("Showing 15 of 20 issues");
|
||||
});
|
||||
|
||||
it("falls back to an emphasized identifier and 'unassigned' when fields are missing", () => {
|
||||
const { blocks, accent } = renderSlackMessage(
|
||||
renderToIR(
|
||||
<IssueList issues={[{ identifier: "CPK-9", title: "No assignee" }]} />,
|
||||
),
|
||||
);
|
||||
const json = JSON.stringify(blocks);
|
||||
// No url → bold identifier, no link wrapper.
|
||||
expect(json).toContain("*CPK-9*");
|
||||
expect(json).not.toContain("|*CPK-9*>");
|
||||
expect(json).toContain("unassigned");
|
||||
// No urgent/high priority → Linear purple.
|
||||
expect(accent).toBe("#5E6AD2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueCard component", () => {
|
||||
it("renders a status header, linked title and a fields grid", () => {
|
||||
const { blocks, accent } = renderSlackMessage(
|
||||
renderToIR(
|
||||
<IssueCard
|
||||
identifier="CPK-101"
|
||||
title="Checkout 500s under load"
|
||||
url="https://linear.app/copilotkit/issue/CPK-101"
|
||||
state="In Progress"
|
||||
assignee="Alem"
|
||||
priority="Urgent"
|
||||
team="CPK"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const json = JSON.stringify(blocks);
|
||||
// Header: in-progress unicode dot + identifier (plain_text, untouched).
|
||||
expect(blocks[0]).toMatchObject({
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: "🔵 CPK-101" },
|
||||
});
|
||||
// Title section with the linked, bold title.
|
||||
expect(json).toContain(
|
||||
"<https://linear.app/copilotkit/issue/CPK-101|*Checkout 500s under load*>",
|
||||
);
|
||||
// A section carries the 2-column metadata grid.
|
||||
const fieldsSection = blocks.find(
|
||||
(b) => b.type === "section" && "fields" in b && Array.isArray(b.fields),
|
||||
) as { fields: { text: string }[] } | undefined;
|
||||
expect(fieldsSection).toBeDefined();
|
||||
expect(fieldsSection?.fields).toHaveLength(4);
|
||||
expect(json).toContain("*Assignee*\\nAlem");
|
||||
expect(json).toContain("*Priority*\\n🚨 Urgent");
|
||||
expect(json).toContain("*Status*\\n🔵 In Progress");
|
||||
expect(json).toContain("*Team*\\nCPK");
|
||||
// Footer: "Open in Linear" link.
|
||||
expect(json).toContain(
|
||||
"<https://linear.app/copilotkit/issue/CPK-101|Open in Linear →>",
|
||||
);
|
||||
// Urgent priority drives the accent.
|
||||
expect(accent).toBe("#EB5757");
|
||||
});
|
||||
|
||||
it("shows a 'Filed' banner and a check header when justCreated", () => {
|
||||
const { blocks, accent } = renderSlackMessage(
|
||||
renderToIR(
|
||||
<IssueCard identifier="CPK-200" title="New bug" justCreated />,
|
||||
),
|
||||
);
|
||||
const json = JSON.stringify(blocks);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: "✅ CPK-200" },
|
||||
});
|
||||
expect(json).toContain("✨ Filed in Linear");
|
||||
// The Filed banner sits before the fields grid.
|
||||
const bannerIdx = blocks.findIndex(
|
||||
(b) =>
|
||||
b.type === "context" && JSON.stringify(b).includes("Filed in Linear"),
|
||||
);
|
||||
const fieldsIdx = blocks.findIndex(
|
||||
(b) => b.type === "section" && "fields" in b,
|
||||
);
|
||||
expect(bannerIdx).toBeGreaterThan(-1);
|
||||
expect(bannerIdx).toBeLessThan(fieldsIdx);
|
||||
// unassigned fallback + Status placeholder grid still render.
|
||||
expect(json).toContain("_unassigned_");
|
||||
// No priority/state → Linear purple.
|
||||
expect(accent).toBe("#5E6AD2");
|
||||
});
|
||||
|
||||
it("appends a divider + trimmed description when present", () => {
|
||||
const long = "x".repeat(700);
|
||||
const { blocks } = renderSlackMessage(
|
||||
renderToIR(
|
||||
<IssueCard identifier="CPK-300" title="Big" description={long} />,
|
||||
),
|
||||
);
|
||||
expect(blocks.filter((b) => b.type === "divider")).toHaveLength(1);
|
||||
const descSection = blocks[blocks.length - 1] as {
|
||||
text?: { text: string };
|
||||
};
|
||||
// Description is trimmed to 600 chars + an ellipsis.
|
||||
expect(descSection.text?.text).toBe(`${"x".repeat(600)}…`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageList component", () => {
|
||||
it("renders linked titles, snippets and a count footer", () => {
|
||||
const { blocks, accent } = renderSlackMessage(
|
||||
renderToIR(
|
||||
<PageList
|
||||
heading="Runbooks"
|
||||
pages={[
|
||||
{
|
||||
title: "Auth outage runbook",
|
||||
url: "https://www.notion.so/abc",
|
||||
snippet: "Steps to mitigate auth provider downtime.",
|
||||
edited: "3d ago",
|
||||
},
|
||||
{ title: "No-link page" },
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
const json = JSON.stringify(blocks);
|
||||
expect(blocks[0]).toMatchObject({
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: "📚 Runbooks" },
|
||||
});
|
||||
expect(json).toContain("<https://www.notion.so/abc|*Auth outage runbook*>");
|
||||
expect(json).toContain("Steps to mitigate auth provider downtime.");
|
||||
expect(json).toContain("🕒 edited 3d ago");
|
||||
// A page without a url renders as bold text rather than a link.
|
||||
expect(json).toContain("*No-link page*");
|
||||
expect(json).not.toContain("|*No-link page*>");
|
||||
expect(json).toContain("2 pages");
|
||||
// Exactly one divider between the two pages.
|
||||
expect(blocks.filter((b) => b.type === "divider")).toHaveLength(1);
|
||||
// Notion-dark accent.
|
||||
expect(accent).toBe("#2F3437");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Telegram parity tests ────────────────────────────────────────────────────
|
||||
// These tests render the same IR through renderTelegram and assert that the
|
||||
// unicode status/priority glyphs appear correctly (no Slack `:shortcode:`
|
||||
// strings that Telegram would not expand).
|
||||
|
||||
describe("IssueCard Telegram parity", () => {
|
||||
it("renders unicode status and priority glyphs in Telegram output", () => {
|
||||
const payload = renderTelegram(
|
||||
renderToIR(
|
||||
<IssueCard
|
||||
identifier="CPK-101"
|
||||
title="Checkout 500s under load"
|
||||
url="https://linear.app/copilotkit/issue/CPK-101"
|
||||
state="In Progress"
|
||||
assignee="Alem"
|
||||
priority="Urgent"
|
||||
team="CPK"
|
||||
/>,
|
||||
),
|
||||
);
|
||||
// renderTelegram returns a TelegramPayload with a `text` field (HTML string)
|
||||
// and `parseMode: "HTML"` — confirmed from telegram.test.ts line:
|
||||
// expect(out.parseMode).toBe("HTML");
|
||||
// expect(out.text).toContain("<b>Status</b>");
|
||||
expect(typeof payload.text).toBe("string");
|
||||
// In-progress maps to the blue dot unicode glyph.
|
||||
expect(payload.text).toContain("🔵");
|
||||
// Urgent priority maps to the siren glyph.
|
||||
expect(payload.text).toContain("🚨");
|
||||
// Identifier and title text must appear in the output.
|
||||
expect(payload.text).toContain("CPK-101");
|
||||
expect(payload.text).toContain("Checkout 500s under load");
|
||||
// No Slack mrkdwn shortcodes must appear.
|
||||
expect(payload.text).not.toContain(":large_blue_circle:");
|
||||
expect(payload.text).not.toContain(":rotating_light:");
|
||||
});
|
||||
|
||||
it("renders 'done' unicode glyph for justCreated issue in Telegram output", () => {
|
||||
const payload = renderTelegram(
|
||||
renderToIR(
|
||||
<IssueCard identifier="CPK-200" title="New bug" justCreated />,
|
||||
),
|
||||
);
|
||||
expect(typeof payload.text).toBe("string");
|
||||
// justCreated uses the check-mark glyph.
|
||||
expect(payload.text).toContain("✅");
|
||||
expect(payload.text).toContain("CPK-200");
|
||||
expect(payload.text).toContain("New bug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IssueList Telegram parity", () => {
|
||||
it("renders unicode status glyphs for each issue in Telegram output", () => {
|
||||
const payload = renderTelegram(
|
||||
renderToIR(
|
||||
<IssueList
|
||||
heading="Open"
|
||||
issues={[
|
||||
{
|
||||
identifier: "CPK-101",
|
||||
title: "Checkout 500s under load",
|
||||
url: "https://linear.app/copilotkit/issue/CPK-101",
|
||||
state: "In Progress",
|
||||
assignee: "Alem",
|
||||
priority: "Urgent",
|
||||
updated: "2d ago",
|
||||
},
|
||||
{
|
||||
identifier: "CPK-102",
|
||||
title: "Login redirect loop",
|
||||
url: "https://linear.app/copilotkit/issue/CPK-102",
|
||||
state: "Todo",
|
||||
assignee: "Sam",
|
||||
priority: "High",
|
||||
updated: "5h ago",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(typeof payload.text).toBe("string");
|
||||
// In-progress maps to the blue dot.
|
||||
expect(payload.text).toContain("🔵");
|
||||
// Todo/unknown maps to the orange dot.
|
||||
expect(payload.text).toContain("🟠");
|
||||
// Identifiers must be present.
|
||||
expect(payload.text).toContain("CPK-101");
|
||||
expect(payload.text).toContain("CPK-102");
|
||||
// No Slack mrkdwn shortcodes.
|
||||
expect(payload.text).not.toContain(":large_blue_circle:");
|
||||
expect(payload.text).not.toContain(":large_orange_circle:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageList Telegram parity", () => {
|
||||
it("renders page titles and snippets in Telegram output", () => {
|
||||
const payload = renderTelegram(
|
||||
renderToIR(
|
||||
<PageList
|
||||
heading="Runbooks"
|
||||
pages={[
|
||||
{
|
||||
title: "Auth outage runbook",
|
||||
url: "https://www.notion.so/abc",
|
||||
snippet: "Steps to mitigate auth provider downtime.",
|
||||
edited: "3d ago",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(typeof payload.text).toBe("string");
|
||||
expect(payload.text).toContain("Auth outage runbook");
|
||||
expect(payload.text).toContain("Steps to mitigate auth provider downtime.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared status/priority → glyph mapping for the Linear components. The
|
||||
* functions return unicode glyphs (not Slack `:shortcode:` strings), so the
|
||||
* components render identically on Slack and Telegram.
|
||||
*/
|
||||
|
||||
/** Unicode glyph for a Linear workflow-state name. */
|
||||
export function stateGlyph(state?: string): string {
|
||||
const s = (state ?? "").toLowerCase();
|
||||
if (s.includes("done") || s.includes("complete")) return "✅";
|
||||
if (s.includes("progress") || s.includes("started")) return "🔵";
|
||||
if (s.includes("review")) return "🟣";
|
||||
if (s.includes("cancel")) return "🚫";
|
||||
if (s.includes("backlog")) return "⚪";
|
||||
return "🟠";
|
||||
}
|
||||
|
||||
/** Unicode glyph for a Linear priority label, or "" for none/unknown. */
|
||||
export function priorityGlyph(priority?: string): string {
|
||||
const p = (priority ?? "").toLowerCase();
|
||||
if (p.includes("urgent")) return "🚨";
|
||||
if (p.includes("high")) return "🔴";
|
||||
if (p.includes("medium")) return "🟠";
|
||||
if (p.includes("low")) return "⚪";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Brand + semantic accent colors for the attachment left-border. */
|
||||
export const ACCENT = {
|
||||
linear: "#5E6AD2",
|
||||
notion: "#2F3437",
|
||||
urgent: "#EB5757",
|
||||
high: "#F2994A",
|
||||
done: "#27AE60",
|
||||
progress: "#2D9CDB",
|
||||
canceled: "#9B9B9B",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Accent color for a single issue: priority wins (urgent/high), then state
|
||||
* (done/in-progress/canceled), falling back to Linear purple.
|
||||
*/
|
||||
export function accentForIssue(issue: {
|
||||
state?: string;
|
||||
priority?: string;
|
||||
}): string {
|
||||
const p = (issue.priority ?? "").toLowerCase();
|
||||
if (p.includes("urgent")) return ACCENT.urgent;
|
||||
if (p.includes("high")) return ACCENT.high;
|
||||
const s = (issue.state ?? "").toLowerCase();
|
||||
if (s.includes("done") || s.includes("complete")) return ACCENT.done;
|
||||
if (s.includes("cancel")) return ACCENT.canceled;
|
||||
if (s.includes("progress") || s.includes("started")) return ACCENT.progress;
|
||||
return ACCENT.linear;
|
||||
}
|
||||
|
||||
/** Accent for a list: surface the hottest priority present, else Linear purple. */
|
||||
export function accentForIssues(
|
||||
issues: ReadonlyArray<{ priority?: string }>,
|
||||
): string {
|
||||
const prios = issues.map((i) => (i.priority ?? "").toLowerCase());
|
||||
if (prios.some((p) => p.includes("urgent"))) return ACCENT.urgent;
|
||||
if (prios.some((p) => p.includes("high"))) return ACCENT.high;
|
||||
return ACCENT.linear;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* App-specific render components — agent-renderable Block Kit cards authored
|
||||
* with the `@copilotkit/channels-ui` JSX vocabulary.
|
||||
*
|
||||
* Each component is a plain `ComponentFn` returning a `<Message>` tree; its
|
||||
* exported zod prop schema doubles as the render-tool input schema. Render a
|
||||
* component with `renderSlackMessage(renderToIR(<IssueCard {...props} />))`.
|
||||
*/
|
||||
export { IssueCard, issueCardSchema } from "./issue-card.js";
|
||||
export type { IssueCardProps } from "./issue-card.js";
|
||||
|
||||
export { IssueList, issueListSchema } from "./issue-list.js";
|
||||
export type { IssueListProps } from "./issue-list.js";
|
||||
|
||||
export { PageList, pageListSchema } from "./page-list.js";
|
||||
export type { PageListProps } from "./page-list.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* `issue_card` — a rich single-issue card: a header with the status + id,
|
||||
* the title as a link, a two-column metadata grid (status / assignee /
|
||||
* priority / team / cycle / updated), an optional description, and an
|
||||
* optional labels + "Open in Linear" footer.
|
||||
*
|
||||
* Use it for one issue — when the user asks about a specific issue, or
|
||||
* right after creating one (it doubles as the "filed!" confirmation).
|
||||
*
|
||||
* Authored with the `@copilotkit/channels-ui` JSX vocabulary; the Block Kit
|
||||
* shapes are produced by `renderSlackMessage(renderToIR(<IssueCard .../>))`.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Context,
|
||||
Divider,
|
||||
Fields,
|
||||
Field,
|
||||
Header,
|
||||
Message,
|
||||
Section,
|
||||
} from "@copilotkit/channels-ui";
|
||||
import type { BotNode } from "@copilotkit/channels-ui";
|
||||
import { accentForIssue, priorityGlyph, stateGlyph } from "./_status.js";
|
||||
|
||||
export const issueCardSchema = z.object({
|
||||
identifier: z.string().describe("Issue identifier, e.g. 'CPK-1234'."),
|
||||
title: z.string().describe("Issue title."),
|
||||
url: z.string().optional().describe("Link to the issue in Linear."),
|
||||
state: z.string().optional().describe("Workflow state name."),
|
||||
assignee: z.string().optional().describe("Assignee display name."),
|
||||
priority: z.string().optional().describe("Priority label."),
|
||||
team: z.string().optional().describe("Team key/name, e.g. 'CPK'."),
|
||||
cycle: z.string().optional().describe("Cycle name/number."),
|
||||
updated: z.string().optional().describe("Human-readable last-updated."),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Issue description (markdown). Kept short; long text is trimmed.",
|
||||
),
|
||||
labels: z.array(z.string()).optional().describe("Label names."),
|
||||
justCreated: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Set true right after creating the issue to show a 'Filed' banner.",
|
||||
),
|
||||
});
|
||||
|
||||
export type IssueCardProps = z.infer<typeof issueCardSchema>;
|
||||
|
||||
/** Render ONE Linear issue as a rich Block Kit card. */
|
||||
export function IssueCard(issue: IssueCardProps): BotNode {
|
||||
const titleText = issue.url
|
||||
? `[**${issue.title}**](${issue.url})`
|
||||
: `**${issue.title}**`;
|
||||
|
||||
const prio = priorityGlyph(issue.priority);
|
||||
|
||||
const description = issue.description
|
||||
? issue.description.length > 600
|
||||
? `${issue.description.slice(0, 600)}…`
|
||||
: issue.description
|
||||
: undefined;
|
||||
|
||||
const footer: string[] = [];
|
||||
if (issue.labels?.length) footer.push(`🏷️ ${issue.labels.join(" ")}`);
|
||||
if (issue.url) footer.push(`[Open in Linear →](${issue.url})`);
|
||||
const footerText = footer.length ? footer.join(" · ") : undefined;
|
||||
|
||||
return (
|
||||
<Message accent={accentForIssue(issue)}>
|
||||
<Header>
|
||||
{`${issue.justCreated ? "✅ " : `${stateGlyph(issue.state)} `}${issue.identifier}`}
|
||||
</Header>
|
||||
<Section>{titleText}</Section>
|
||||
{issue.justCreated ? <Context>{"✨ Filed in Linear"}</Context> : null}
|
||||
<Fields>
|
||||
<Field>{`**Status**\n${stateGlyph(issue.state)} ${issue.state ?? "—"}`}</Field>
|
||||
<Field>{`**Assignee**\n${issue.assignee ?? "_unassigned_"}`}</Field>
|
||||
{issue.priority ? (
|
||||
<Field>{`**Priority**\n${prio ? `${prio} ` : ""}${issue.priority}`}</Field>
|
||||
) : null}
|
||||
{issue.team ? <Field>{`**Team**\n${issue.team}`}</Field> : null}
|
||||
{issue.cycle ? <Field>{`**Cycle**\n${issue.cycle}`}</Field> : null}
|
||||
{issue.updated ? (
|
||||
<Field>{`**Updated**\n${issue.updated}`}</Field>
|
||||
) : null}
|
||||
</Fields>
|
||||
{description ? <Divider /> : null}
|
||||
{description ? <Section>{description}</Section> : null}
|
||||
{footerText ? <Context>{footerText}</Context> : null}
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* `issue_list` — renders a set of Linear issues as a compact Block Kit card:
|
||||
* a header, ONE section with one scannable line per issue (status dot + linked
|
||||
* identifier + title + assignee · updated), and a count footer.
|
||||
*
|
||||
* This is deliberately a fixed THREE-block layout (header + section + context)
|
||||
* regardless of issue count: a card-per-issue layout (~3 blocks each) blows
|
||||
* past Slack's per-attachment block limit on long lists and gets rejected with
|
||||
* `invalid_attachments`. We instead inline up to `MAX` issues into a single
|
||||
* section and surface the overflow in the footer.
|
||||
*
|
||||
* The agent fetches issues from the Linear MCP server and passes the fields
|
||||
* it wants shown; the Slack formatting lives here. For a single issue (or
|
||||
* right after creating one) prefer `issue_card`, which shows a full grid.
|
||||
*
|
||||
* Authored with the `@copilotkit/channels-ui` JSX vocabulary.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { Context, Header, Message, Section } from "@copilotkit/channels-ui";
|
||||
import type { BotNode } from "@copilotkit/channels-ui";
|
||||
import { accentForIssues, stateGlyph } from "./_status.js";
|
||||
|
||||
const issueSchema = z.object({
|
||||
identifier: z.string().describe("Linear issue identifier, e.g. 'CPK-1234'."),
|
||||
title: z.string().describe("Issue title."),
|
||||
url: z.string().optional().describe("Link to the issue in Linear."),
|
||||
state: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Workflow state name, e.g. 'Todo', 'In Progress', 'Done'."),
|
||||
assignee: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Assignee display name, or omit if unassigned."),
|
||||
priority: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Priority label, e.g. 'Urgent', 'High', 'Medium', 'Low'."),
|
||||
updated: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Human-readable last-updated, e.g. '2d ago'."),
|
||||
});
|
||||
|
||||
export const issueListSchema = z.object({
|
||||
heading: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional heading, e.g. 'Open CPK issues this cycle'."),
|
||||
issues: z.array(issueSchema).min(1).describe("The issues to render."),
|
||||
});
|
||||
|
||||
export type IssueListProps = z.infer<typeof issueListSchema>;
|
||||
type Issue = z.infer<typeof issueSchema>;
|
||||
|
||||
/** Max issues rendered inline; the rest are summarized in the footer. */
|
||||
const MAX = 15;
|
||||
/** Max title length before trimming (keeps each line scannable). */
|
||||
const TITLE_MAX = 70;
|
||||
|
||||
/** Render a list of Linear issues as a compact, fixed-size Block Kit card. */
|
||||
export function IssueList({ heading, issues }: IssueListProps): BotNode {
|
||||
const lines = issues.slice(0, MAX).map((issue: Issue) => {
|
||||
const idLink = issue.url
|
||||
? `[**${issue.identifier}**](${issue.url})`
|
||||
: `**${issue.identifier}**`;
|
||||
const title =
|
||||
issue.title.length > TITLE_MAX
|
||||
? `${issue.title.slice(0, TITLE_MAX)}…`
|
||||
: issue.title;
|
||||
const meta = `${issue.assignee ?? "unassigned"}${issue.updated ? ` · ${issue.updated}` : ""}`;
|
||||
return `${stateGlyph(issue.state)} ${idLink} ${title} — ${meta}`;
|
||||
});
|
||||
|
||||
const footer =
|
||||
issues.length > MAX
|
||||
? `Showing ${MAX} of ${issues.length} issues`
|
||||
: `${issues.length} issue${issues.length === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<Message accent={accentForIssues(issues)}>
|
||||
<Header>{`📋 ${heading ?? "Linear issues"}`}</Header>
|
||||
<Section>{lines.join("\n")}</Section>
|
||||
<Context>{footer}</Context>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `page_list` — renders Notion page search results as a Block Kit card:
|
||||
* a header, then one row per page (📄 linked title + a greyed snippet and
|
||||
* optional last-edited), with dividers and a count footer.
|
||||
*
|
||||
* The agent searches Notion via MCP and passes the pages it wants to
|
||||
* surface; the Slack formatting lives here.
|
||||
*
|
||||
* Authored with the `@copilotkit/channels-ui` JSX vocabulary.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Context,
|
||||
Divider,
|
||||
Header,
|
||||
Message,
|
||||
Section,
|
||||
} from "@copilotkit/channels-ui";
|
||||
import type { BotNode } from "@copilotkit/channels-ui";
|
||||
import { ACCENT } from "./_status.js";
|
||||
|
||||
const pageSchema = z.object({
|
||||
title: z.string().describe("Page title."),
|
||||
url: z.string().optional().describe("Link to the page in Notion."),
|
||||
snippet: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("A short excerpt or summary of the page."),
|
||||
editedBy: z.string().optional().describe("Who last edited it, if known."),
|
||||
edited: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Human-readable last-edited, e.g. '3d ago'."),
|
||||
});
|
||||
|
||||
export const pageListSchema = z.object({
|
||||
heading: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional heading, e.g. 'Runbooks matching “auth outage”'."),
|
||||
pages: z.array(pageSchema).min(1).describe("The pages to render."),
|
||||
});
|
||||
|
||||
export type PageListProps = z.infer<typeof pageListSchema>;
|
||||
type Page = z.infer<typeof pageSchema>;
|
||||
|
||||
/** Render a list of Notion pages as a Block Kit card. */
|
||||
export function PageList({ heading, pages }: PageListProps): BotNode {
|
||||
const rows: BotNode[] = [];
|
||||
pages.forEach((page: Page, i: number) => {
|
||||
const titleLink = page.url
|
||||
? `[**${page.title}**](${page.url})`
|
||||
: `**${page.title}**`;
|
||||
|
||||
const meta = [
|
||||
page.snippet,
|
||||
page.edited
|
||||
? `🕒 edited ${page.edited}${page.editedBy ? ` by ${page.editedBy}` : ""}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
rows.push(<Section>{`📄 ${titleLink}`}</Section>);
|
||||
if (meta) rows.push(<Context>{meta}</Context>);
|
||||
if (i < pages.length - 1) rows.push(<Divider />);
|
||||
});
|
||||
|
||||
return (
|
||||
<Message accent={ACCENT.notion}>
|
||||
<Header>{`📚 ${heading ?? "Notion pages"}`}</Header>
|
||||
{rows}
|
||||
<Context>{`${pages.length} page${pages.length === 1 ? "" : "s"}`}</Context>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user