lib/assistant-context.ts
Rama main · réplica de lectura del repositorio original.
// The context of Numo, seen as a LIST of pills.
//
// `AssistantPageContext` is a flat object (an open ticket, a view, a
// cycle…): practical to send, illegible to display. This module makes
// pills — one per context element — and knows how to go back the other way:
// when the user turns off the eye of a pill, we remove the sent context
// EXACTLY the fields that this pill represented. The message therefore persists
// what Numo actually saw, not what the page was posting.
import type { ObjectiveStatus } from "@/lib/objective-validation";
import type { useTranslations } from "next-intl";
import { orbSeedOr } from "@/lib/project-orb-colors";
import { selectionExcerpt } from "@/lib/assistant-selection";
import type {
AssistantPageContext,
AssistantPinnedContext,
AssistantSelectedItemKind,
} from "@/lib/assistant-types";
export type AssistantContextKind =
| "project"
| "issue"
| "pull_request"
| "issues"
| "objective"
| "feedback"
| "routine"
| "inbox"
| "page"
| "view"
| "cycle"
| "settings"
| "admin"
| "member"
| "selection";
export interface AssistantContextChip {
/** Stable identity of the pill — this is what we deselect. */
key: string;
kind: AssistantContextKind;
/** What is written on the pill. */
label: string;
/** Tooltip: the detail that the wording left out. */
tooltip: string;
/**
* Members: the seed of the portrait. Projects: the id, seed of the orb.
* In both cases the pill shows the REAL figure of the thing rather
* than a generic icon.
*/
avatarSeed?: string;
/** Projects: the imported favicon, when the project has one. */
iconUrl?: string | null;
/** Objectives: the color used for their target icon throughout the app. */
color?: string | null;
/** Objectives: their status, whose glyph replaces the target (MIN-785). */
objectiveStatus?: ObjectiveStatus;
/** Wiki pages: their emoji, when they have one. */
icon?: string | null;
/** Hand pinned (@ button) — removeable, where ambient turns off. */
pinned?: boolean;
/**
* Removed with a cross like a pin, without being one: the quoted selection
* belongs to the message being written, there is nothing to turn back on.
*/
removable?: boolean;
}
/** Pill key of the quoted selection (`AssistantPageContext.selection`). */
export const SELECTION_CHIP_KEY = "selection";
/** Pill key of a list surface's selected records (`AssistantPageContext.selectedItems`). */
export const SELECTED_ITEMS_CHIP_KEY = "selected_items";
const SELECTED_ITEMS_LABEL = {
feedback: "contextFeedbackSelected",
routine: "contextRoutinesSelected",
objective: "contextObjectivesSelected",
page: "contextPagesSelected",
} as const satisfies Record<AssistantSelectedItemKind, string>;
/** Pill key of a pinned context. */
export function pinnedKey(item: AssistantPinnedContext): string {
return `pinned:${item.kind}:${item.id}`;
}
/** The Assistant namespace translator. Without the namespace, TypeScript gives up
(TS2589) and no longer checks anything — see CLAUDE.md. */
type Translate = ReturnType<typeof useTranslations<"Assistant">>;
/**
* A context's pills, in the order they read: project
* first (largest), then what's open, then view and cycle,
* then what the user pinned themselves.
*
* `scopeProjectId` is the ambient route project for this message. It never
* derives from the conversation history.
*/
export function contextChips(
ctx: AssistantPageContext | null | undefined,
opts: {
t: Translate;
scopeProjectId?: string | null;
/** Resolves a project id (name + icon) via the client projects context. */
project?: (
id: string,
) =>
| { name: string; icon_url: string | null; orb_seed: string | null }
| undefined;
},
): AssistantContextChip[] {
const { t } = opts;
const chips: AssistantContextChip[] = [];
const projectId = ctx?.projectId ?? opts.scopeProjectId ?? undefined;
if (projectId) {
const project = opts.project?.(projectId);
chips.push({
key: "project",
kind: "project",
label: project?.name ?? t("contextProject"),
tooltip: project
? t("contextProjectTooltip", { name: project.name })
: t("contextProject"),
avatarSeed: orbSeedOr(projectId, project?.orb_seed),
iconUrl: project?.icon_url ?? null,
});
}
if (ctx?.issueId) {
// The identifier alone: short, stable, it is enough to recognize the ticket.
// The title goes into the tooltip.
chips.push({
key: "issue",
kind: "issue",
label: ctx.issueIdentifier ?? ctx.issueTitle ?? t("contextIssue"),
tooltip: ctx.issueTitle ?? t("contextIssue"),
});
} else if (ctx?.issueIds && ctx.issueIds.length > 0) {
// Group selection of a board: the account is enough on the pill, the list
// identifiers fit in the tooltip.
chips.push({
key: "issues",
kind: "issues",
label: t("contextIssuesSelected", { count: ctx.issueIds.length }),
tooltip: ctx.issueIdentifiers?.length
? ctx.issueIdentifiers.join(", ")
: t("contextIssuesSelected", { count: ctx.issueIds.length }),
});
}
// A group selected on a list surface (MIN-873): the count on the pill,
// the titles in the tooltip, like a board's ticket selection.
const selectedItems = ctx?.selectedItems ?? [];
if (selectedItems.length > 0) {
const kind = selectedItems[0].kind;
// The four labels share one `{count}` plural; a key union defeats
// next-intl's per-key value typing.
const label = t(SELECTED_ITEMS_LABEL[kind] as "contextFeedbackSelected", {
count: selectedItems.length,
});
chips.push({
key: SELECTED_ITEMS_CHIP_KEY,
kind,
label,
tooltip: selectedItems.map((item) => item.label.trim() || label).join(", "),
});
}
if (ctx?.pullRequestId) {
const label = ctx.prNumber != null ? `#${ctx.prNumber}` : "Pull request";
chips.push({
key: "pull_request",
kind: "pull_request",
label,
tooltip: ctx.prHeadRef ? `${label} · ${ctx.prHeadRef}` : label,
});
}
if (ctx?.inbox) {
chips.push({
key: "inbox",
kind: "inbox",
label: t("contextInbox"),
tooltip: t("contextInboxTooltip"),
});
}
if (ctx?.settings) {
const label =
ctx.settings === "account"
? t("contextAccountSettings")
: t("contextProjectSettings");
chips.push({
key: "settings",
kind: "settings",
label,
tooltip: label,
});
}
if (ctx?.admin) {
const label = t(
ctx.admin === "accounts"
? "contextAdminAccounts"
: ctx.admin === "costs"
? "contextAdminCosts"
: ctx.admin === "instance"
? "contextAdminInstance"
: "contextAdminModels",
);
chips.push({ key: "admin", kind: "admin", label, tooltip: t("contextAdminTooltip") });
}
if (ctx?.objectiveId) {
chips.push({
key: "objective",
kind: "objective",
label: ctx.objectiveName ?? t("contextObjective"),
tooltip: t("contextObjective"),
color: ctx.objectiveColor ?? null,
objectiveStatus: ctx.objectiveStatus,
});
}
if (ctx?.feedbackId) {
chips.push({
key: "feedback",
kind: "feedback",
label: ctx.feedbackTitle ?? t("contextFeedback"),
tooltip: t("contextFeedback"),
});
}
if (ctx?.pageId) {
chips.push({
key: "page",
kind: "page",
label: ctx.pageTitle?.trim() || t("contextPage"),
tooltip: t("contextPage"),
icon: ctx.pageIcon ?? null,
});
}
if (ctx?.routineId) {
chips.push({
key: "routine",
kind: "routine",
label: ctx.routineTitle ?? t("contextRoutine"),
tooltip: t("contextRoutine"),
});
}
if (ctx?.viewId || ctx?.onglet) {
// The name of the view takes precedence; the tab only concerns persistent messages
// before v2 views (only one tab since).
const label =
ctx.viewName ??
(ctx.onglet === "my" ? t("contextBoardMy") : t("contextBoardAll"));
chips.push({
key: "view",
kind: "view",
label,
tooltip: t("contextBoard"),
});
}
if (ctx?.cycleId) {
const label = t("contextCycle", { label: ctx.cycleLabel ?? "" });
chips.push({ key: "cycle", kind: "cycle", label, tooltip: label });
}
// The quoted passage reads after what it was taken from (the page, the
// issue) and before the pins: an excerpt on the pill, the passage in its
// tooltip.
const selectionText = ctx?.selection?.text?.trim();
if (selectionText) {
chips.push({
key: SELECTION_CHIP_KEY,
kind: "selection",
label: t("contextSelection", { excerpt: selectionExcerpt(selectionText) }),
tooltip: t("contextSelectionTooltip", { text: selectionText }),
removable: true,
});
}
for (const item of ctx?.pinned ?? []) {
// Pinned or ambient, a project keeps the same figure: its orb (or its
// favicon), resolved here as the scope one.
const project =
item.kind === "project" ? opts.project?.(item.id) : undefined;
chips.push({
key: pinnedKey(item),
kind: item.kind,
label: item.label,
tooltip: item.detail ?? item.label,
...(item.kind === "member"
? { avatarSeed: item.avatarSeed ?? item.id }
: item.kind === "project"
? { avatarSeed: orbSeedOr(item.id, project?.orb_seed) }
: {}),
...(item.kind === "project"
? { iconUrl: project?.icon_url ?? null }
: {}),
...(item.kind === "objective"
? { color: item.color ?? null, objectiveStatus: item.objectiveStatus }
: {}),
...(item.kind === "page" ? { icon: item.icon ?? null } : {}),
pinned: true,
});
}
return chips;
}
/** The fields that each ambient pill represents — what turning off the eye removes. */
const FIELDS_BY_KEY: Record<string, (keyof AssistantPageContext)[]> = {
project: ["projectId"],
inbox: ["inbox"],
settings: ["settings"],
admin: ["admin"],
issue: [
"issueId",
"issueIdentifier",
"issueTitle",
],
pull_request: [
"pullRequestId",
"prNumber",
"prState",
"prHeadRef",
"prBaseRef",
"prRunId",
],
issues: ["issueIds", "issueProjectIds", "issueIdentifiers", "issueTitles"],
objective: ["objectiveId", "objectiveName", "objectiveColor"],
feedback: ["feedbackId", "feedbackTitle"],
routine: ["routineId", "routineTitle"],
page: ["pageId", "pageTitle", "pageIcon", "pageIsDatabase"],
view: ["viewId", "viewName", "onglet"],
cycle: ["cycleId", "cycleLabel"],
[SELECTION_CHIP_KEY]: ["selection"],
[SELECTED_ITEMS_CHIP_KEY]: ["selectedItems"],
};
/**
* The context actually sent: that of the page, minus the extinguished pills.
* Returns `null` when there is nothing left — the API then does not attach any block of
* context to the prompt.
*/
export function applyContextSelection(
ctx: AssistantPageContext | null | undefined,
disabled: ReadonlySet<string>,
): AssistantPageContext | null {
if (!ctx) return null;
const next: AssistantPageContext = { ...ctx };
for (const [key, fields] of Object.entries(FIELDS_BY_KEY)) {
if (!disabled.has(key)) continue;
for (const field of fields) delete next[field];
}
if (next.pinned) {
const kept = next.pinned.filter((item) => !disabled.has(pinnedKey(item)));
if (kept.length > 0) next.pinned = kept;
else delete next.pinned;
}
const hasAnything = Object.values(next).some((v) => v !== undefined);
return hasAnything ? next : null;
}
/**
* The context of the page enriched with the scope (the current project) and the
* pinned elements — the full form, before deselection.
*/
export function withPinnedContext(
ctx: AssistantPageContext | null | undefined,
opts: { scopeProjectId?: string | null; pinned: AssistantPinnedContext[] },
): AssistantPageContext | null {
const projectId = ctx?.projectId ?? opts.scopeProjectId ?? undefined;
if (!ctx && !projectId && opts.pinned.length === 0) return null;
return {
...ctx,
...(projectId ? { projectId } : {}),
...(opts.pinned.length > 0 ? { pinned: opts.pinned } : {}),
};
}