# D — Projects, prompt library, context/file library (2026-09-11) Workstream D of the PolyLLM 1.0 upgrade. Everything below compiles (`pnpm typecheck` / `pnpm lint` clean for the D files; remaining typecheck errors at hand-off time are in A/B/C/E/F files still being edited) and `pnpm test` passes (`tests/unit/prompt-variables.test.ts` added, 7 cases). ## Files ### Services (server) - `src/lib/projects/service.ts` — `listProjects`, `getProject`, `getProjectDetail`, `createProject`, `updateProject`, `deleteProject` (keeps conversations: `project_id → null`; files cascade; prompts detached), `moveConversations`, `listUnassignedConversations`, `toPublicProject` (`PublicProject`, `ProjectDetail` types). - `src/lib/projects/schemas.ts` — zod `projectBodySchema` (POST; PATCH = `.partial()`), `projectActionSchema`. - `src/lib/library/service.ts` — `listFiles`, `getFile`, `saveFile` (kinds/limits mirror `/api/attachments`: 10 MB, 2 MB text; PNG/JPEG dimensions; `estimatedTokens` via `estimateAttachmentTokens`; 500 files/user cap), `updateFile` (rename / description / move to project), `deleteFile`, `attachFiles` (copies into `message_attachments` with `messageId`/`conversationId` null — exactly what POST `/api/attachments` produces; the chat service links them on send), `toPublicFile` (`PublicProjectFile`, `PendingAttachment`). - `src/lib/prompts/variables.ts` — pure, client-safe: `parseVariables`, `mergeVariables`, `renderTemplate`, `labelFor`, `variablesToAsk`, `VARIABLE_RE` (`{{name}}`, whitespace tolerated, names `[A-Za-z_][\w.-]*`; JSON braces untouched). - `src/lib/prompts/service.ts` — `listPrompts` (search over name/description/content/tags, kind/folder/tag/favorite/project filters, returns `folders` + `tags` facets), `getPrompt`, `createPrompt`, `updatePrompt` (variables re-merged with content), `deletePrompt`, `usePrompt` (uses++, `lastUsedAt`, renders), `PROMPT_KINDS`, `toPublicPrompt` (`PublicPrompt`, `PromptKind`, `UsePromptResult`). - `src/lib/prompts/schemas.ts` — zod `promptBodySchema`, `promptVariableSchema`, `promptUseSchema`. - `src/lib/conversations/service.ts` — **additive**: `projectId` in `ListFilter` (`"none"` supported), `createConversation`, `updateConversation`, and `PublicConversation` (now an explicit interface; `projectId?: string | null` is optional so A's optimistic conversation object stays assignable — A may set `projectId: activeProjectId` there). - `src/lib/client/types.ts` — appended re-exports: `PublicProject`, `ProjectDetail`, `PublicProjectFile`, `PendingAttachment`, `PublicPrompt`, `PromptKind`, `UsePromptResult`, `PromptVariable`. - Ids: `prj_…` (projects), `pfl_…` (library files) via `newId()`; prompts reuse `ids.prompt()` (`prm_…`, distinct table from `prompt_presets`). ### Routes - `src/app/api/projects/route.ts`, `src/app/api/projects/[id]/route.ts` - `src/app/api/library/files/route.ts`, `src/app/api/library/files/[id]/route.ts`, `src/app/api/library/files/attach/route.ts` - `src/app/api/prompts/route.ts`, `src/app/api/prompts/[id]/route.ts`, `src/app/api/prompts/[id]/use/route.ts` - `src/app/api/conversations/route.ts` (`?projectId=`, `projectId` in create body), `src/app/api/conversations/[id]/route.ts` (`projectId` in PATCH). ### UI - Pages: `src/app/app/projects/page.tsx`, `src/app/app/projects/[id]/page.tsx`, `src/app/app/library/page.tsx`, `src/app/app/prompts/page.tsx` (replaced). - `src/components/projects/`: `page-header.tsx` (`WorkspacePageHeader` 48 px bar with hamburger/back + `WorkspacePageBody`), `project-icon.tsx`, `project-form-sheet.tsx` (create/edit: name, emoji, colour, description), `projects-view.tsx` (grid, Active/Archived, search, card menu, `?new=1`), `project-detail.tsx` (tabs Chats / Files / Prompts / Instructions & models / Notes, stats, `?tab=`), `project-conversations.tsx` (rows, remove, "Add existing chats" picker), `project-setup.tsx` (instructions, preferred models via `ModelSelector multiple`, default settings via `ParametersForm`, sticky save bar), `project-notes.tsx` (markdown write/preview, ⌘S), `project-switcher.tsx` (ActionSheet on phone / dropdown on desktop), `sidebar-section.tsx` (finished: count, active dot + "Active", switcher button, overflow link, skeleton), `row-menu.tsx` (shared ⋯ menu: ActionSheet + long-press on phone, hover dropdown on desktop). - `src/components/library/`: `file-library-picker.tsx` (finished: search, scope chips, multi-select ≤ 10, token total, inline upload), `file-list.tsx` (`FileList`, `FileThumb`, `FileKindIcon`, preview sheet, move/describe/delete), `library-view.tsx` (filters by project & kind, drag-and-drop upload on desktop), `upload-button.tsx` (`UploadButton`, `uploadLibraryFiles`, `reportUpload`), `use-in-chat.ts` (files → new chat hand-off), `format.ts`. - `src/components/prompts/`: `prompt-library.tsx` (search, kind Segmented, favourites/folders/tags chips, `?new=1`/`?edit=`/`?content=&kind=` deep links, legacy section), `prompt-card.tsx` (card + compact row), `prompt-editor-sheet.tsx` (live variable detection with label/default/options/required, JSON schema for structured, folder datalist, tag chips, default model, project), `prompt-use-sheet.tsx` (fill-in + preview + `insertPromptIntoChat`), `legacy-presets.tsx` (lists `prompt_presets`, Import / Import all / legacy Use / Delete), `prompt-kind.tsx`, `insert.ts` (event contract, see below). - `tests/unit/prompt-variables.test.ts`. ## API All routes: `withUser` (401 otherwise), errors as `{ error: { code, message } }`. | Method | Path | Body / query → response | | --- | --- | --- | | GET | `/api/projects?archived=1` | → `{ projects: PublicProject[] }` (non-archived by default; each has `conversationCount`, `fileCount`, `promptCount`) | | POST | `/api/projects` | `{ name, description?, icon?, color?, instructions?, preferredModelKeys?, defaultSettings?, notes?, archived?, sortOrder? }` → 201 `{ project }` | | GET | `/api/projects/:id` | → `{ project, conversations: PublicConversation[], files: PublicProjectFile[], prompts: PublicPrompt[], stats: { conversations, messages, files, prompts, totalCostUsd, totalInputTokens, totalOutputTokens, fileBytes, fileTokens, lastActivityAt } }`; `?unassigned=1` → `{ conversations }` outside any project | | PATCH | `/api/projects/:id` | any subset of the create body (`defaultSettings` validated by `generationSettingsSchema`) → `{ project }` | | POST | `/api/projects/:id` | `{ action: "add-conversations" \| "remove-conversations", conversationIds }` → `{ moved }` | | DELETE | `/api/projects/:id` | conversations kept (`project_id` null), files cascade, prompts detached → `{ ok }` | | GET | `/api/library/files?projectId=&q=&kind=&limit=` | → `{ files: PublicProjectFile[] }` (metadata only) | | POST | `/api/library/files` | multipart `file`, `projectId?`, `description?` → 201 `{ file }` (415 unsupported, 413 > 10 MB / text > 2 MB, 409 library full) | | DELETE | `/api/library/files?id=` | → `{ ok }` | | GET | `/api/library/files/:id` | streams the payload (`inline`; `?download=1` → attachment; `?meta=1` → `{ file }`) | | PATCH | `/api/library/files/:id` | `{ name?, description?, projectId? }` (`projectId: null` = global) → `{ file }` | | DELETE | `/api/library/files/:id` | → `{ ok }` | | POST | `/api/library/files/attach` | `{ fileIds: string[] (1–10) }` → 201 `{ attachments: [{ id, kind, name, mimeType, sizeBytes, width, height }] }` — pending `message_attachments` rows; pass the ids in `message.attachmentIds` | | GET | `/api/prompts?q=&kind=&folder=&tag=&favorite=1&projectId=&limit=` | → `{ prompts, folders: string[], tags: string[], total }` (command palette: `?q=`) | | POST | `/api/prompts` | `{ kind?, name, content, description?, variables?, schema?, folder?, tags?, favorite?, defaultModelKey?, projectId? }` → 201 `{ prompt }` (variables auto-merged with `{{…}}` in content) | | GET / PATCH / DELETE | `/api/prompts/:id` | → `{ prompt }` / `{ prompt }` / `{ ok }` | | POST | `/api/prompts/:id/use` | `{ variables? }` → `{ prompt, kind, rendered, text, systemPrompt, schema, defaultModelKey, missing, defaulted }` (uses++, `lastUsedAt`) | | GET | `/api/conversations?projectId=` | additive filter | | POST / PATCH | `/api/conversations`, `/api/conversations/:id` | `projectId: string \| null` accepted | ## Prompt → composer contract (`src/components/prompts/insert.ts`) — for workstream A 1. Library calls `POST /api/prompts/:id/use { variables }`, then `dispatchPromptInsert(detail)`: - `window.dispatchEvent(new CustomEvent("polyllm:insert-prompt", { detail }))` (`PROMPT_INSERT_EVENT`), and - `sessionStorage["polyllm:pending-prompt-insert"]` = same JSON (`PENDING_PROMPT_KEY`), for a chat view that mounts later; - then `router.push("/app/chat?promptInsert=&vars=")` (`promptChatHref`). 2. `PromptInsertDetail = { promptId, name, kind, text, systemPrompt, schema, defaultModelKey, variables, at }`. 3. Chat view should: - `usePromptInsert(handler)` for live inserts while mounted; - on mount of a new chat: `consumePendingPromptInsert()` (fast path, returns null if absent/older than 5 min); if only URL params are present (shared link, reload): `POST /api/prompts//use` with `decodeVars(vars)` and apply the result the same way; - apply: `kind === "system"` → set the conversation system prompt to `systemPrompt`; `user`/`template` → insert `text` into the draft; `structured` → insert `text` and, if `schema`, request `responseFormat: { type: "json_schema", schema }`; `defaultModelKey` may switch the model when usable. - Legacy `?prompt=` keeps its current behaviour (unchanged); optionally fall back to `GET /api/prompts/:id` when the id is not a legacy preset. 4. "Save as prompt" from a message: navigate to `/app/prompts?new=1&kind=&content=` — the library opens the editor prefilled. ## Library files → composer contract (`src/components/library/use-in-chat.ts`) — for workstream A `prepareFilesForNewChat(fileIds)` → POST attach, stashes `{ at, attachments }` in `sessionStorage["polyllm:pending-attachments"]` (`PENDING_ATTACHMENTS_KEY`) and returns `/app/chat?attachments=` (plus `&project=` when launched from a project). Chat view: on mount of a new chat read `?attachments=` and `consumePendingAttachments()` to seed composer chips; the ids go straight into `message.attachmentIds`. `FileLibraryPicker` (already imported by the composer) keeps its `{ open, onOpenChange, onPick(PickedAttachment[]), projectId? }` API. Project context for chat (already planned in A): with `activeProjectId` set, fetch `/api/projects/:id` → `project.instructions` (system prompt), `project.preferredModelKeys[0]` (default model), `project.defaultSettings`; create conversations with `projectId: activeProjectId` (POST `/api/conversations` accepts it; the chat service path that auto-creates a conversation should also set `projectId` — TODO(integration: chat) one field in `lib/chat/service.ts`). ## Coming soon / not done - No drag-to-reorder of preferred models (there is a "Make default" action instead) and no project `sortOrder` UI (API supports it). - Command palette entries (Open project, Search prompts) are G's; `GET /api/prompts?q=` and `GET /api/projects` are ready. - Text preview in the library caps at 40 KB; PDFs open in a new tab (no inline viewer). - Legacy presets can be imported/used/deleted but not edited on the new page (import, then edit in the library). ## QA checklist (visual, integration phase) - 375/390/430 px: `/app/projects` header 48 px with hamburger; cards stack 1-col; long-press a card → ActionSheet; "+" in sidebar opens the create sheet as a bottom sheet (emoji grid, colour radios ≥ 40 px). - `/app/projects/:id`: back chevron; Segmented tabs scroll horizontally without clipping; Chats tab rows 56 px, long-press → Remove; "Add existing" sheet full-height with search; Files tab upload (button) + thumbnails; Setup tab: `ModelSelector` opens with multi-select, list with Default badge, sticky "Save setup" bar appears only when dirty; Notes: Write/Preview segmented on phone, side-by-side ≥ lg. - Sidebar/drawer: active project shows dot + "Active"; ⇄ opens ActionSheet (phone) / dropdown (desktop); "+N more…" when > 8. - `/app/library`: chips row scrolls; kind chips appear when > 3 files; row ⋯ → Preview (image/text sheet), Download, Move, Description (PromptDialog), Delete (ConfirmDialog); desktop drag-and-drop overlay; upload errors toast per file (unsupported type, > 10 MB). - Composer "From library": picker shows scope chips when a project is active, ≤ 10 selection, token total in footer, inline Upload; attached chips appear in composer (A). - `/app/prompts`: editor sheet full-height on phone; typing `{{x}}` adds a variable row live; structured kind shows schema textarea with JSON validation; Use on a prompt with variables → fill-in sheet with preview → lands on `/app/chat?promptInsert=…` with the text inserted (A); Use without variables inserts directly; legacy presets section lists old prompts, Import creates a system prompt in folder "Imported presets". - No `window.prompt/confirm` anywhere; every icon button has an aria-label; all new inputs are ≥ 15 px on phones.