// 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; /** 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>; /** * 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 = { 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, ): 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 } : {}), }; }