diff --git a/docs/specs/2026-06-01-chat-inventory-intake-design.md b/docs/specs/2026-06-01-chat-inventory-intake-design.md index 9707481..0c7a798 100644 --- a/docs/specs/2026-06-01-chat-inventory-intake-design.md +++ b/docs/specs/2026-06-01-chat-inventory-intake-design.md @@ -310,3 +310,54 @@ Each phase is independently shippable. - Promoting the registry so the chat consumes capabilities *over* MCP (Approach 3), once capabilities stabilize. - Optional light gate on the intake front door if draft spam becomes a problem. + +--- + +## Amendments + +Appended per [`DRIFT.md`](DRIFT.md). Original text above is never edited. + +### 2026-09-14 — §6.1 vision now reaches the model (as-built fix) + +**What changed.** §6.1 requires the photo's bytes on the outgoing user message. The chat +client never sent them: `ChatFab` called `sendMessage({ text })`, so only the +`[Attached photos: …]` hint went out and the model never saw a photo, although the route +already accepted image parts. Now, alongside the Notion upload, the browser draws a JPEG copy +no larger than 1568px on the long edge and sends it as a file part on the same message. The +hint is unchanged, so `create_tool` still attaches the same Notion upload. + +**Two details §6.1 did not specify.** + +1. **Earlier photos are bounded.** The chat re-sends the whole conversation every turn. The + latest user message keeps all its photos; earlier turns keep at most four more, newest + first (`withRecentPhotos`), so a follow-up about a recent photo still works without every + photo riding along forever (Article 4). +2. **A photo the browser cannot decode still uploads.** HEIC in most browsers produces no copy + for the model, which is exactly the behaviour before this fix. + +**Why it was missed.** No test followed a photo from the client to the model; the component +test checked only the hint text. Both ends are now asserted: `ChatFab.test.tsx` checks the file +part is sent, and the chat route test checks it reaches `streamText`. + +**Status.** Accepted. The spec was right and the code did not meet it. + +### 2026-09-14 — the duplicate card no longer offers "add a unit" (amends §4.4) + +**What changed.** §4.4's duplicate card offered **Add a unit to the existing tool**. The button +seeded a message nothing could act on: `create_tool` always creates a new tool, and no tool +adds a unit to an existing one. The card now offers **No, create a new tool** and **Discard**, +and the prompt tells the assistant to point at the existing listing and say that staff add +units for now. The prompt also now says how to resolve **No, create a new tool**, which it +never listed. `intake.actionAddUnit` is removed from all 12 locale files. + +**Why remove rather than build.** Adding units is being redesigned as part of moving v5's data +off Notion, and a button that visibly does nothing is worse than no button. + +**Status.** Accepted. + +### 2026-09-14 — intake is staff-only (answers §11's light gate) + +§11 left open a gate on the intake front door. It now exists — see the auth spec's amendment +of the same date. Anonymous visitors and students cannot use intake on the chat surface. + +**Status.** Accepted. diff --git a/docs/specs/2026-07-29-auth-and-rate-limiting-design.md b/docs/specs/2026-07-29-auth-and-rate-limiting-design.md index 8e1dc44..5e5fa43 100644 --- a/docs/specs/2026-07-29-auth-and-rate-limiting-design.md +++ b/docs/specs/2026-07-29-auth-and-rate-limiting-design.md @@ -412,3 +412,26 @@ duplicated in `auth.spec.ts`. **Status.** Accepted. The spec's intent (no live OAuth, assert the header states) is met; the mechanism is one layer up from the cookie because the suite deliberately has no secret. + +### 2026-09-14 — adding equipment is staff-only (amends §3.4 and §8) + +**What changed.** §3.4 and §8 say no capability is role-gated in v5. The intake capability +(`research_tool`, `propose_listing`, `create_tool`) now declares a minimum role of `staff`. +On the chat surface an anonymous visitor or a signed-in student gets none of its tools, and +its prompt fragment is replaced by a short note telling the assistant that adding equipment +is limited to staff. The header's "Add" entry point appears only for staff and admins. + +**Where the rule lives.** A capability declares `minimumRole`; `capabilitiesForRole` in +`v5/src/lib/capabilities/access.ts` enforces it once, when the chat composes its tools. That +keeps §3.4's rule that authorization is enforced once rather than per tool, and no tool's +`run()` checks a role. MCP is unchanged: its trust boundary is `MCP_TOKEN`, which already +gates `create_tool` there, and an MCP caller has no role to check. + +**Why.** Drafts-by-default (Article 5) kept unreviewed listings out of the catalog, but not out +of Notion: any visitor could create draft pages and trigger paid web research. The ISAM demo +puts the live app in front of a public audience on shared wifi, and the lab's direction as of +2026-09-14 is that admins and SuperMakers add equipment, not visitors. Until in-app roles +exist, SuperMakers belong on `AUTH_STAFF_EMAILS`. + +**Status.** Accepted. Covered by `src/lib/capabilities/access.test.ts`, the chat route tests +(anonymous, student, staff), `intake.test.ts`, and `PrimaryNav.test.tsx`. diff --git a/docs/specs/README.md b/docs/specs/README.md index d3642d7..fb03197 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -73,6 +73,17 @@ The banner is the one worth learning from: **no test and no mechanical check cou caught it.** A missing banner adds no surface and breaks nothing. It took opening the page and noticing the catalogue claimed two machines with nothing saying they were invented. +### Pre-ISAM fixes, 2026-09-14 + +Found by reading the code ahead of the ISAM demo. None of them adds or removes surface, so +`spec:coverage` could not see them. + +| Finding | Bucket | Outcome | +|---|---|---| +| Intake §6.1 **photos never reached the model** — the client sent only the text hint | NOT-BUILT | **Closed** — a downscaled image part goes out with the message; intake spec amended | +| Intake §4.4 **"add a unit" button** seeded a message no tool could act on | NOT-BUILT | **Closed** — button withdrawn; intake spec amended | +| **Anyone could add equipment**, anonymous visitors included | DECISION | **Closed** — intake is staff-only; auth spec amended | + Also here: [`2026-05-29-v5-test-suite-design.md`](2026-05-29-v5-test-suite-design.md) (implemented) and [`2026-06-01-chat-inventory-intake-design.md`](2026-06-01-chat-inventory-intake-design.md) (implemented; extended by #8). diff --git a/v5/messages/ar.json b/v5/messages/ar.json index 4d6f485..6b77e51 100644 --- a/v5/messages/ar.json +++ b/v5/messages/ar.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "إنه {variant}", "actionEdit": "تعديل", "actionDiscard": "تجاهل", - "actionAddUnit": "أضِف وحدة إلى الأداة الموجودة", "actionCreateAnyway": "لا، أنشئ أداة جديدة", "actionOpenDraft": "افتح المسودة في Notion" }, diff --git a/v5/messages/en.json b/v5/messages/en.json index 57d88c6..7a0f004 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "It’s the {variant}", "actionEdit": "Edit", "actionDiscard": "Discard", - "actionAddUnit": "Add a unit to the existing tool", "actionCreateAnyway": "No, create a new tool", "actionOpenDraft": "Open draft in Notion" }, diff --git a/v5/messages/es.json b/v5/messages/es.json index d780a09..aeb4250 100644 --- a/v5/messages/es.json +++ b/v5/messages/es.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "Es el {variant}", "actionEdit": "Editar", "actionDiscard": "Descartar", - "actionAddUnit": "Añadir una unidad a la herramienta existente", "actionCreateAnyway": "No, crear una herramienta nueva", "actionOpenDraft": "Abrir el borrador en Notion" }, diff --git a/v5/messages/fr.json b/v5/messages/fr.json index 4943881..2302fd0 100644 --- a/v5/messages/fr.json +++ b/v5/messages/fr.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "C’est le {variant}", "actionEdit": "Modifier", "actionDiscard": "Écarter", - "actionAddUnit": "Ajouter une unité à l’outil existant", "actionCreateAnyway": "Non, créer un nouvel outil", "actionOpenDraft": "Ouvrir le brouillon dans Notion" }, diff --git a/v5/messages/he.json b/v5/messages/he.json index ca97469..abb3e72 100644 --- a/v5/messages/he.json +++ b/v5/messages/he.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "זה {variant}", "actionEdit": "עריכה", "actionDiscard": "התעלמות", - "actionAddUnit": "הוספת יחידה לכלי הקיים", "actionCreateAnyway": "לא, ליצור כלי חדש", "actionOpenDraft": "פתיחת הטיוטה ב-Notion" }, diff --git a/v5/messages/hi.json b/v5/messages/hi.json index 476722a..2dfe5ca 100644 --- a/v5/messages/hi.json +++ b/v5/messages/hi.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "यह {variant} है", "actionEdit": "संपादित करें", "actionDiscard": "छोड़ दें", - "actionAddUnit": "मौजूदा टूल में एक यूनिट जोड़ें", "actionCreateAnyway": "नहीं, नया टूल बनाएँ", "actionOpenDraft": "Notion में ड्राफ़्ट खोलें" }, diff --git a/v5/messages/ja.json b/v5/messages/ja.json index 4dc7d15..51fbbed 100644 --- a/v5/messages/ja.json +++ b/v5/messages/ja.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "{variant} です", "actionEdit": "編集", "actionDiscard": "破棄", - "actionAddUnit": "既存のツールにユニットを追加", "actionCreateAnyway": "いいえ、新しいツールを作成", "actionOpenDraft": "Notion で下書きを開く" }, diff --git a/v5/messages/ko.json b/v5/messages/ko.json index 7fa4a95..1d38b4e 100644 --- a/v5/messages/ko.json +++ b/v5/messages/ko.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "{variant}입니다", "actionEdit": "편집", "actionDiscard": "버리기", - "actionAddUnit": "기존 도구에 유닛 추가", "actionCreateAnyway": "아니요, 새 도구 만들기", "actionOpenDraft": "Notion에서 초안 열기" }, diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json index 040884f..d2c9bcb 100644 --- a/v5/messages/pt-BR.json +++ b/v5/messages/pt-BR.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "É o {variant}", "actionEdit": "Editar", "actionDiscard": "Descartar", - "actionAddUnit": "Adicionar uma unidade à ferramenta existente", "actionCreateAnyway": "Não, criar uma ferramenta nova", "actionOpenDraft": "Abrir o rascunho no Notion" }, diff --git a/v5/messages/ru.json b/v5/messages/ru.json index a9686b2..ba642d7 100644 --- a/v5/messages/ru.json +++ b/v5/messages/ru.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "Это {variant}", "actionEdit": "Изменить", "actionDiscard": "Отклонить", - "actionAddUnit": "Добавить единицу к существующему инструменту", "actionCreateAnyway": "Нет, создать новый инструмент", "actionOpenDraft": "Открыть черновик в Notion" }, diff --git a/v5/messages/tr.json b/v5/messages/tr.json index 97d2708..68d2053 100644 --- a/v5/messages/tr.json +++ b/v5/messages/tr.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "Bu {variant}", "actionEdit": "Düzenle", "actionDiscard": "Vazgeç", - "actionAddUnit": "Mevcut araca bir birim ekle", "actionCreateAnyway": "Hayır, yeni bir araç oluştur", "actionOpenDraft": "Taslağı Notion’da aç" }, diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json index 30fa461..804200e 100644 --- a/v5/messages/zh-CN.json +++ b/v5/messages/zh-CN.json @@ -221,7 +221,6 @@ "actionConfirmVariant": "是 {variant}", "actionEdit": "编辑", "actionDiscard": "丢弃", - "actionAddUnit": "为已有工具添加一台设备", "actionCreateAnyway": "不,创建新工具", "actionOpenDraft": "在 Notion 中打开草稿" }, diff --git a/v5/src/app/api/chat/route.test.ts b/v5/src/app/api/chat/route.test.ts index d56e5c1..dd38811 100644 --- a/v5/src/app/api/chat/route.test.ts +++ b/v5/src/app/api/chat/route.test.ts @@ -563,3 +563,98 @@ describe("PDF manual collection (focused tool)", () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +// ── Adding equipment is staff-only (auth spec amendment 2026-09-14) ── +describe("POST /api/chat — who may add equipment", () => { + const INTAKE_TOOLS = ["research_tool", "propose_listing", "create_tool"]; + const SECRET = "chat-route-test-secret"; + const ASK = "I'd like to add new equipment to the inventory."; + + async function postAs(email: string, name: string) { + vi.stubEnv("AUTH_SECRET", SECRET); + const token = await signSession( + createSessionPayload({ sub: `sub-${email}`, email, name }), + SECRET + ); + await POST( + chatRequest( + { messages: [userMessage(ASK)] }, + { cookie: `${SESSION_COOKIE_NAME}=${token}` } + ) + ); + } + + it("gives an anonymous visitor no intake tools, and tells the assistant why", async () => { + await POST(chatRequest({ messages: [userMessage(ASK)] })); + + for (const name of INTAKE_TOOLS) { + expect(captured.args.tools).not.toHaveProperty(name); + } + expect(captured.args.system).toContain("limited to lab staff"); + expect(captured.args.system).not.toContain("act as an intake agent"); + }); + + it("gives a signed-in student no intake tools either", async () => { + await postAs("ada@cornell.edu", "Ada Lovelace"); + + for (const name of INTAKE_TOOLS) { + expect(captured.args.tools).not.toHaveProperty(name); + } + expect(captured.args.system).toContain("limited to lab staff"); + }); + + it("gives staff the intake tools and the full intake instructions", async () => { + vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu"); + await postAs("niti@cornell.edu", "Niti Parikh"); + + for (const name of INTAKE_TOOLS) { + expect(captured.args.tools).toHaveProperty(name); + } + expect(captured.args.system).toContain("act as an intake agent"); + expect(captured.args.system).not.toContain("limited to lab staff"); + }); + + it("keeps reporting a problem open to anonymous visitors", async () => { + await POST(chatRequest({ messages: [userMessage("the printer is jammed")] })); + expect(captured.args.tools).toHaveProperty("report_issue"); + }); +}); + +// ── Photos reach the model (intake spec §6.1) ──────────────────────── +describe("POST /api/chat — photos reach the model", () => { + it("passes an attached photo to the model on the user message", async () => { + await POST( + chatRequest({ + messages: [ + { + id: "1", + role: "user", + parts: [ + { + type: "text", + text: "what printer is this?\n\n[Attached photos: file_upload_id=fu_1 name=plate.jpg]", + }, + { + type: "file", + mediaType: "image/jpeg", + filename: "plate.jpg", + url: "data:image/jpeg;base64,AAAA", + }, + ], + }, + ], + }) + ); + + const user = captured.args.messages.find((m: any) => m.role === "user"); + expect(user.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "file", + mediaType: "image/jpeg", + data: "data:image/jpeg;base64,AAAA", + }), + ]) + ); + }); +}); diff --git a/v5/src/app/api/chat/route.ts b/v5/src/app/api/chat/route.ts index 67f741d..0443e36 100644 --- a/v5/src/app/api/chat/route.ts +++ b/v5/src/app/api/chat/route.ts @@ -20,7 +20,11 @@ import { checkRateLimit, type RateLimitDecision } from "../../../lib/rate-limit" import { resolveIdentity } from "../../../lib/auth/identity"; import { siteConfig } from "../../../lib/site-config"; import { chatModel } from "../../../lib/model"; -import { CAPABILITIES, composeChat } from "../../../lib/capabilities"; +import { + CAPABILITIES, + capabilitiesForRole, + composeChat, +} from "../../../lib/capabilities"; import type { CapabilityCtx, UploadedImage, @@ -117,11 +121,15 @@ export async function POST(req: Request) { // then add the provider-native research tools (Anthropic web tools are not // capabilities — the intake capability's prompt tells the agent to use // them). web_fetch keeps the focused-tool domain allow-list. - const { tools: capabilityTools, system } = composeChat(CAPABILITIES, ctx, { - tools, - focusedTool: focused, - locale, - }); + // + // The registry is composed as this caller may use it: a capability whose + // minimum role they do not meet contributes no tools, only a note on why + // (auth spec amendment 2026-09-14). + const { tools: capabilityTools, system } = composeChat( + capabilitiesForRole(CAPABILITIES, identity.role), + ctx, + { tools, focusedTool: focused, locale } + ); const result = streamText({ model: chatModel, diff --git a/v5/src/components/ChatFab.test.tsx b/v5/src/components/ChatFab.test.tsx index 17b4896..9fb7d99 100644 --- a/v5/src/components/ChatFab.test.tsx +++ b/v5/src/components/ChatFab.test.tsx @@ -73,6 +73,16 @@ vi.mock("../lib/auth/sign-in-client", async (importOriginal) => { }; }); +// The copy of a photo the model sees is drawn on a canvas, which jsdom does not +// have. Its own behaviour is covered in `lib/chat/downscale-image.test.ts`; here +// each test decides whether the browser could encode the photo. +const downscaleForVision = vi.hoisted(() => + vi.fn<(file: Blob) => Promise>(async () => null) +); +vi.mock("../lib/chat/downscale-image", () => ({ + downscaleForVision: (file: Blob) => downscaleForVision(file), +})); + // Imported after the mocks above are hoisted. import { ChatFab } from "./ChatFab"; @@ -322,6 +332,44 @@ describe("ChatFab", () => { expect(opts.transport).toBeDefined(); expect(typeof opts.onData).toBe("function"); }); + + it("bounds earlier photos in the request it sends", () => { + render(); + const { transport } = lastUseChatOptions as { + transport: { + prepareSendMessagesRequest: (options: { + id: string; + messages: unknown[]; + trigger: string; + messageId: string | undefined; + }) => { body: { messages: Array<{ parts: Array<{ type: string }> }> } }; + }; + }; + const photo = (n: number) => ({ + type: "file", + mediaType: "image/jpeg", + filename: `p${n}.jpg`, + url: `data:image/jpeg;base64,P${n}`, + }); + + const { body } = transport.prepareSendMessagesRequest({ + id: "chat-1", + messages: [ + { + id: "u1", + role: "user", + parts: [{ type: "text", text: "earlier" }, ...[1, 2, 3, 4, 5, 6].map(photo)], + }, + { id: "a1", role: "assistant", parts: [{ type: "text", text: "ok" }] }, + { id: "u2", role: "user", parts: [{ type: "text", text: "a follow-up" }] }, + ], + trigger: "submit-message", + messageId: undefined, + }); + + const earlierPhotos = body.messages[0].parts.filter((p) => p.type === "file"); + expect(earlierPhotos).toHaveLength(4); + }); }); // ── Citation stripping (#22) ─────────────────────────────────────── @@ -534,6 +582,80 @@ describe("ChatFab — photo uploads", () => { screen.queryByRole("button", { name: /^Remove / }) ).not.toBeInTheDocument(); }); + + async function attachPhotoAndSend( + user: ReturnType, + message: string + ) { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ file_upload_id: "fu_123", name: "plate.jpg" }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ) + ); + render(); + await user.click( + screen.getByRole("button", { name: "Open MakerLab assistant" }) + ); + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + await user.upload( + fileInput, + new File([new Uint8Array([1, 2, 3])], "plate.jpg", { type: "image/jpeg" }) + ); + await screen.findByRole("button", { name: "Remove plate.jpg" }); + await user.type( + screen.getByRole("textbox", { name: "Ask the lab console" }), + message + ); + await user.click(screen.getByRole("button", { name: "Send" })); + } + + it("sends the photo itself with the message, so the model can see it", async () => { + // Intake spec §6.1: the Notion upload is the record and the downscaled copy + // is what the model looks at. Both go out on the same message. + downscaleForVision.mockClear(); + downscaleForVision.mockResolvedValue("data:image/jpeg;base64,SMALL"); + const user = userEvent.setup(); + + await attachPhotoAndSend(user, "what printer is this?"); + + expect(downscaleForVision).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(1); + const arg = sendMessage.mock.calls[0][0] as { + text: string; + files?: unknown[]; + }; + expect(arg.text).toContain( + "[Attached photos: file_upload_id=fu_123 name=plate.jpg]" + ); + expect(arg.files).toEqual([ + { + type: "file", + mediaType: "image/jpeg", + filename: "plate.jpg", + url: "data:image/jpeg;base64,SMALL", + }, + ]); + }); + + it("still sends the upload hint when the browser cannot encode the photo", async () => { + downscaleForVision.mockResolvedValue(null); + const user = userEvent.setup(); + + await attachPhotoAndSend(user, "what printer is this?"); + + expect(sendMessage).toHaveBeenCalledWith({ + text: expect.stringContaining( + "[Attached photos: file_upload_id=fu_123 name=plate.jpg]" + ), + }); + }); }); /** diff --git a/v5/src/components/ChatFab.tsx b/v5/src/components/ChatFab.tsx index 1cb4bb9..96a8586 100644 --- a/v5/src/components/ChatFab.tsx +++ b/v5/src/components/ChatFab.tsx @@ -1,7 +1,7 @@ "use client"; import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; +import { DefaultChatTransport, type FileUIPart } from "ai"; import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -13,6 +13,8 @@ import { useChatLauncher } from "./ChatLauncherContext"; import { siteConfig } from "../lib/site-config"; import { startGoogleSignIn } from "../lib/auth/sign-in-client"; import type { CardPayload } from "../lib/capabilities/types"; +import { downscaleForVision } from "../lib/chat/downscale-image"; +import { toVisionFileParts, withRecentPhotos } from "../lib/chat/photo-parts"; interface Suggestion { icon: "search" | "clipboard" | "pin"; @@ -222,6 +224,8 @@ interface PendingPhoto { file_upload_id: string; name: string; previewUrl: string; + /** Downscaled copy the model sees; absent when the browser could not encode it. */ + dataUrl?: string; } export function ChatFab() { @@ -258,7 +262,9 @@ export function ChatFab() { prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => ({ body: { id, - messages, + // Every turn re-sends the conversation; keep earlier photos bounded + // so their bytes do not ride along forever (Article 4). + messages: withRecentPhotos(messages), trigger, messageId, locale: localeRef.current, @@ -345,10 +351,16 @@ export function ChatFab() { try { const form = new FormData(); form.append("file", file); - const res = await fetch("/api/upload-notion", { - method: "POST", - body: form, - }); + // The Notion upload is the record; the downscaled copy is what the + // model looks at (intake spec §6.1). They run side by side, and a + // photo the browser cannot encode still uploads. + const [res, dataUrl] = await Promise.all([ + fetch("/api/upload-notion", { + method: "POST", + body: form, + }), + downscaleForVision(file), + ]); if (!res.ok) { const body = (await res.json().catch(() => null)) as | { error?: string } @@ -366,6 +378,7 @@ export function ChatFab() { file_upload_id: data.file_upload_id, name: data.name, previewUrl, + dataUrl: dataUrl ?? undefined, }, ]); } catch (err) { @@ -483,9 +496,9 @@ export function ChatFab() { // previous turn. Clearing on send (rather than in an effect reacting to // `status`) keeps it next to where the request actually starts and avoids a // synchronous setState-in-effect cascade. - function send(text: string) { + function send(text: string, files: FileUIPart[] = []) { setReadingManuals(null); - sendMessage({ text }); + sendMessage(files.length > 0 ? { text, files } : { text }); } // Auto-send a seeded message when something outside ChatFab (e.g. the nav @@ -537,7 +550,7 @@ export function ChatFab() { .join("; "); outgoing = `${text}\n\n[Attached photos: ${hint}]`; } - send(outgoing); + send(outgoing, toVisionFileParts(pendingPhotos)); setDraft(""); clearPendingPhotos(); setUploadError(null); diff --git a/v5/src/components/PrimaryNav.test.tsx b/v5/src/components/PrimaryNav.test.tsx index ada6b41..13f25ad 100644 --- a/v5/src/components/PrimaryNav.test.tsx +++ b/v5/src/components/PrimaryNav.test.tsx @@ -274,3 +274,43 @@ describe("PrimaryNav — staff refresh control", () => { ).not.toBeInTheDocument(); }); }); + +// en.json: nav.add = "ADD", nav.addAria = "Add new equipment to the inventory". +// Adding equipment is staff-only (auth spec amendment 2026-09-14); the chat +// enforces it server-side, so this only asserts the entry point's visibility. +describe("PrimaryNav — add equipment", () => { + const ADD = "Add new equipment to the inventory"; + + beforeEach(() => { + usePathname.mockReturnValue("/"); + fetchIdentity.mockClear(); + fetchIdentity.mockResolvedValue(null); + }); + + it.each([ + ["staff", "Niti Parikh"], + ["admin", "Isaac Steinberg"], + ] as const)("offers it to %s", async (role, name) => { + fetchIdentity.mockResolvedValue({ role, name }); + render(); + + expect(await screen.findByRole("button", { name: ADD })).toHaveTextContent( + "ADD" + ); + }); + + it("does not offer it to a signed-in student", async () => { + fetchIdentity.mockResolvedValue({ role: "student", name: "Ada Lovelace" }); + render(); + + await screen.findByRole("button", { name: "SIGN OUT" }); + expect(screen.queryByRole("button", { name: ADD })).not.toBeInTheDocument(); + }); + + it("does not offer it to an anonymous visitor", async () => { + render(); + + await screen.findByRole("button", { name: /Sign in/ }); + expect(screen.queryByRole("button", { name: ADD })).not.toBeInTheDocument(); + }); +}); diff --git a/v5/src/components/PrimaryNav.tsx b/v5/src/components/PrimaryNav.tsx index 514b11c..c3da0bd 100644 --- a/v5/src/components/PrimaryNav.tsx +++ b/v5/src/components/PrimaryNav.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { useChatLauncher } from "./ChatLauncherContext"; import { RefreshCatalogButton } from "./RefreshCatalogButton"; +import { canAddEquipment } from "../lib/capabilities/access"; import { siteConfig } from "../lib/site-config"; import { fetchIdentity, @@ -75,14 +76,19 @@ export function PrimaryNav() { {t(link.key)} ))} - + {/* Adding equipment is staff-only (auth spec amendment 2026-09-14), so the + entry point waits for an identity that may use it. The chat enforces + the same rule server-side; hiding the button is only presentation. */} + {canAddEquipment(identity?.role) ? ( + + ) : null}