From 914097abcf01d89cc32c18f27db6ebbc21293166 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Mon, 31 Aug 2026 05:20:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(chat):=20save=20by=20default=20+=20share?= =?UTF-8?q?=20anonymously=20=E2=80=94=20auto-saved=20chats,=20guest-facing?= =?UTF-8?q?=20Share,=20success=20toast,=20action=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../01_anonymous_save_share_api.md | 0 .../55_save_share_ux/02_auto_save_default.md | 0 .../55_save_share_ux/03_share_for_everyone.md | 0 .../55_save_share_ux/04_share_toast.md | 0 .../05_chat_actions_layout.md | 0 app/api/chats.py | 82 ++- frontend/assets/app.js | 371 ++++++---- frontend/assets/styles.css | 120 ++-- frontend/index.html | 99 +-- tests/e2e/test_chat_history.py | 114 ++-- tests/e2e/test_save_share_ux.py | 562 +++++++++++++++ tests/e2e/test_share_chat.py | 77 ++- tests/e2e/test_sources_midstream_bug.py | 10 +- tests/e2e/test_stale_saved_chats.py | 45 +- tests/integration/test_chats_api.py | 148 +++- tests/unit/test_chat_persistence.py | 28 +- tests/unit/test_save_chat_ui.py | 638 +++++++++++++----- 17 files changed, 1803 insertions(+), 491 deletions(-) rename .agent/phases/{todo => complete}/55_save_share_ux/01_anonymous_save_share_api.md (100%) rename .agent/phases/{todo => complete}/55_save_share_ux/02_auto_save_default.md (100%) rename .agent/phases/{todo => complete}/55_save_share_ux/03_share_for_everyone.md (100%) rename .agent/phases/{todo => complete}/55_save_share_ux/04_share_toast.md (100%) rename .agent/phases/{todo => complete}/55_save_share_ux/05_chat_actions_layout.md (100%) create mode 100644 tests/e2e/test_save_share_ux.py diff --git a/.agent/phases/todo/55_save_share_ux/01_anonymous_save_share_api.md b/.agent/phases/complete/55_save_share_ux/01_anonymous_save_share_api.md similarity index 100% rename from .agent/phases/todo/55_save_share_ux/01_anonymous_save_share_api.md rename to .agent/phases/complete/55_save_share_ux/01_anonymous_save_share_api.md diff --git a/.agent/phases/todo/55_save_share_ux/02_auto_save_default.md b/.agent/phases/complete/55_save_share_ux/02_auto_save_default.md similarity index 100% rename from .agent/phases/todo/55_save_share_ux/02_auto_save_default.md rename to .agent/phases/complete/55_save_share_ux/02_auto_save_default.md diff --git a/.agent/phases/todo/55_save_share_ux/03_share_for_everyone.md b/.agent/phases/complete/55_save_share_ux/03_share_for_everyone.md similarity index 100% rename from .agent/phases/todo/55_save_share_ux/03_share_for_everyone.md rename to .agent/phases/complete/55_save_share_ux/03_share_for_everyone.md diff --git a/.agent/phases/todo/55_save_share_ux/04_share_toast.md b/.agent/phases/complete/55_save_share_ux/04_share_toast.md similarity index 100% rename from .agent/phases/todo/55_save_share_ux/04_share_toast.md rename to .agent/phases/complete/55_save_share_ux/04_share_toast.md diff --git a/.agent/phases/todo/55_save_share_ux/05_chat_actions_layout.md b/.agent/phases/complete/55_save_share_ux/05_chat_actions_layout.md similarity index 100% rename from .agent/phases/todo/55_save_share_ux/05_chat_actions_layout.md rename to .agent/phases/complete/55_save_share_ux/05_chat_actions_layout.md diff --git a/app/api/chats.py b/app/api/chats.py index 2c2ceaf..9bcdaf9 100644 --- a/app/api/chats.py +++ b/app/api/chats.py @@ -1,9 +1,22 @@ """Saved-chat API — save and view chat history (phase 50, task 02). -Admin-only CRUD under ``/api/chats`` (the phase-16 -:func:`app.core.auth.require_admin` gate, applied router-wide exactly -like :mod:`app.api.steering`): conversations the owner explicitly -**Saves** are stored in Postgres (``saved_chats``, migration 0008). +Split gate (phase 55, task 01 — owner-locked A1, superseding the +phase-50 "save/history is admin-only" lock): under ``/api/chats`` the +WRITE surface is **public** (no session) — ``POST`` (create, including +the save-then-share ``share: true`` branch), ``PUT /{chat_id}`` (the +re-Save upsert), ``POST /{chat_id}/share``. WHY: a save is the +visitor's OWN conversation; the row id is an unguessable ``uuid4``, the +same trust model as the phase-51 share token — the id/token IS the +credential (a guest holds the handle to what they just saved, exactly +as a link-holder holds a token). The MANAGEMENT surface is +**admin-only** and keeps the phase-16 :func:`app.core.auth.require_admin` +gate — applied per-route on exactly those four decorators (list, +detail, delete, unshare — the owner's History surface; the +:mod:`app.api.steering` router-wide pattern is untouched). Guest chats +appear in the admin's History (saved by default — phase 55). + +Conversations are stored in Postgres (``saved_chats``, migration +0008). A10 extension (owner permission 2026-08-29, recorded per AGENTS.md rule 3 — a recorded revision, not a silent deviation): ``/api/chat`` @@ -27,8 +40,9 @@ PENDING row so it ships in the same INSERT), only when supplied; re-stamps ``sources_version`` to the current generation — a Re-Save is the owner affirming this content against the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01) -``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this -admin-gated router. +``POST /{chat_id}/share`` (public, phase 55) / +``POST /{chat_id}/unshare`` (admin-only — the owner's History +action). Staleness (phase 53, task 03): every saved row carries the ``sources_meta`` generation it was saved against (``sources_version``, @@ -82,10 +96,12 @@ from app.schemas import ( UnshareOut, ) +# Phase 55, task 01 (owner-locked A1): NO router-wide gate here — the +# write surface (create / re-Save / share) is public; exactly the four +# management routes below carry ``dependencies=[Depends(require_admin)]``. router = APIRouter( prefix="/chats", tags=["chats"], - dependencies=[Depends(require_admin)], # phase 16: save/history is admin-only ) #: Auto-title cap (owner-locked convention, phase 50): the first user @@ -151,12 +167,18 @@ def _to_row(row: SavedChat, current_version: int) -> SavedChatRow: ) -@router.get("", response_model=SavedChatList) +@router.get( + "", + response_model=SavedChatList, + dependencies=[Depends(require_admin)], # management surface (phase 55) +) def list_chats( db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatList: """All saved chats, latest activity first (``updated_at desc, id - desc``) — the History page's table order. Each row carries the + desc``) — the History page's table order, admin-only (the owner's + History surface; phase 55 moved the gate per-route). Each row + carries the phase-53 ``stale`` flag: the current generation is read ONCE per request (one PK read of the seeded row) and compared against every row's stamp in the serializer helpers — no per-row queries.""" @@ -174,6 +196,10 @@ def create_chat( ) -> SavedChatOut: """Store one explicitly saved conversation (201). + PUBLIC (phase 55, task 01) — no session required: the save is the + visitor's own conversation; the unguessable ``uuid4`` row id IS the + credential (the phase-51 token's trust model). + Auto-title when ``title`` is absent/blank: the first user message's text, whitespace-collapsed, truncated to 120 chars (owner-locked convention); a conversation with no user message (defensive) falls @@ -217,13 +243,18 @@ def create_chat( return _to_out(row, current_version) -@router.get("/{chat_id}", response_model=SavedChatOut) +@router.get( + "/{chat_id}", + response_model=SavedChatOut, + dependencies=[Depends(require_admin)], # management surface (phase 55) +) def get_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatOut: - """One saved chat, full payload (the ``?chat=`` load); 404 when - the id is unknown. The ``stale`` flag (phase 53) tells the chat + """One saved chat, full payload (the ``?chat=`` load) — + admin-only (the ``?chat=`` boot restore is the owner's "Open" + action, phase 55 A3); 404 when the id is unknown. The ``stale`` flag (phase 53) tells the chat page whether to reveal its stale banner (task 05) before the messages render.""" row = db.get(SavedChat, chat_id) @@ -240,6 +271,10 @@ def update_chat( ) -> SavedChatOut: """Re-Save upsert: full ``messages`` replacement on the same row. + PUBLIC (phase 55, task 01) — no session required (the auto-save + upsert for the visitor's own conversation; same row-id trust model + as :func:`create_chat`). + ``title`` is replaced only when supplied (an absent/blank ``title`` keeps the current one); 404 when the id is unknown. ``updated_at`` bumps via the model's ``onupdate=func.now()`` — the attribute @@ -269,12 +304,17 @@ def update_chat( return _to_out(row, current_version) -@router.delete("/{chat_id}", status_code=204) +@router.delete( + "/{chat_id}", + status_code=204, + dependencies=[Depends(require_admin)], # management surface (phase 55) +) def delete_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> Response: - """Remove a saved chat; 404 when the id is unknown.""" + """Remove a saved chat — admin-only (the owner's History action, + phase 55); 404 when the id is unknown.""""" row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") @@ -290,6 +330,11 @@ def share_chat( ) -> ShareOut: """Turn a saved chat into a public link (phase 51, task 01). + PUBLIC (phase 55, task 01) — no session required: sharing is what + the visitor does with their own conversation (the row-id trust + model of :func:`create_chat`; revoking, by contrast, is the + owner's History action — :func:`unshare_chat` stays admin-only). + Returns ``{"chat_id", "share_url"}`` with ``share_url = "/shared/"`` (200, idempotent — an existing token is returned unchanged; a new token is a 128-bit ``uuid4``). @@ -317,13 +362,20 @@ def share_chat( return ShareOut(chat_id=row.id, share_url=f"/shared/{token}") -@router.post("/{chat_id}/unshare", response_model=UnshareOut) +@router.post( + "/{chat_id}/unshare", + response_model=UnshareOut, + dependencies=[Depends(require_admin)], # management surface (phase 55) +) def unshare_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> UnshareOut: """Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL. + Admin-only (the owner's History action — phase 55 kept the + revocation off the guest's reach, so a guest cannot un-revoke). + Idempotent — an unshared chat unshares cleanly (200, no write). ``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only ``share_token``, same reasoning as :func:`share_chat`); 404 when diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 8829810..975636a 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -124,44 +124,54 @@ * while a turn is in flight. No banner, no scroll (phase 42): the fresh * bubble lands where the old one was. * - * Save the conversation (phase 50, owner-locked 2026-08-29, TODO.md L5): - * the "Save" pill (#save-chat-btn — admin-only, SHIPS HIDDEN, revealed at - * boot only for admin: absent, not hidden, for anonymous) stores the - * CURRENT conversation in Postgres (saved_chats, migration 0008) through - * the admin-only /api/chats CRUD. Upsert semantics keyed by - * `currentChatId` (module scope, string | null): a Save while unlinked - * POSTs /api/chats (the server auto-titles from the first question, - * 120-char cap) and links the conversation to the created row's id; a - * re-Save while linked PUTs the SAME row — the same conversation never - * spawns a second row; a 404 from that PUT (the row was deleted on the - * History page behind our back) unlinks and retries as a create, so a - * stale link can never leave the conversation unsaved. "New chat" - * unlinks (a fresh conversation is unlinked until saved again). Boot + * Auto-save the conversation (phase 55, owner-locked A2, 2026-08-31 — + * the phase-50 Save pill is RETIRED, TODO.md L4 "Save shouldn't be a + * button"): every conversation upserts itself into Postgres + * (saved_chats, migration 0008) at the persistence save points — no + * button, no explicit action. The headless persistConversation() helper + * carries the phase-50 upsert semantics, keyed by `currentChatId` + * (module scope, string | null): a save while unlinked POSTs /api/chats + * (the server auto-titles from the first question, 120-char cap) and + * links the conversation to the created row's id; a save while linked + * PUTs the SAME row — the same conversation never spawns a second row; + * a 404 from that PUT (the row was deleted on the History page behind + * our back) unlinks and retries as a create, so a stale link can never + * wedge the conversation. The module-level `persisting` flag is the + * double-fire guard: the save points can overlap (pagehide during a + * stream), so a call while an upsert is in flight is a no-op — the + * next save point retries. The A2 quiet contract: a FAILED auto-save + * never blocks the conversation — a one-line #send-status note ("Couldn't + * save automatically — will try on the next message."), NO error banner; + * a SUCCESSFUL auto-save is silent (the History page is the visible + * proof — the toast is reserved for share). The row link survives + * reloads: the bor.chat.v1 record carries `chatId` (null when unlinked; + * a pre-55 record without the field reads as null — never throws), so a + * refresh restores the conversation AND its link. "New chat" unlinks + * (a fresh conversation creates a fresh row on its first message). Boot * load: /?chat= with a VALID uuid AND admin fetches the row and * renders its messages through the SAME renderStoredMessage loop as the * phase-14 local restore (sources / thinking / tools / stopped / * deflection — pixel-identical), links currentChatId to the id, and * mirrors the conversation to localStorage (a plain refresh returns to - * it the phase-14 way). The ?chat= param is a ONE-SHOT boot instruction: - * the success path normalizes the URL back to / (history.replaceState), - * so a later refresh — or a "New chat" + refresh — restores the LOCAL - * session (the mirror) instead of re-opening the saved row and evicting - * whatever the owner typed since. Invalid/absent param, anonymous (no - * fetch — the gate would 403), 404, or network failure: the normal local - * restore runs instead (404/network also raise the error banner). Save - * feedback - * is status text only ("Conversation saved." / "Nothing to save yet.") - * — the #send-status live region, never stale (PLAN §7.4); failures get - * the error banner. Phase 14's local persistence is untouched: saving is - * an additional, explicit action. + * it the phase-14 way, link included). The ?chat= param is a ONE-SHOT + * boot instruction: the success path normalizes the URL back to / + * (history.replaceState), so a later refresh — or a "New chat" + + * refresh — restores the LOCAL session (the mirror) instead of + * re-opening the saved row and evicting whatever the owner typed since. + * Invalid/absent param, anonymous (no fetch — the gate would 403), 404, + * or network failure: the normal local restore runs instead (404/network + * also raise the error banner). Phase 14's local persistence is + * untouched: auto-save is an additional, automatic upsert. * * Share the conversation (phase 51, owner-locked 2026-08-29, TODO.md - * L6): the "Share" pill (#share-chat-btn — admin-only, SHIPS HIDDEN, - * revealed at boot in the SAME admin-reveal block as Save) turns the - * CURRENT conversation into a public read-only link (/shared/, - * a 128-bit uuid4 on the saved_chats row). The save-then-share - * contract: the SAME empty-conversation no-op guard as Save (live - * region, no request); linked (currentChatId set) → POST + * L6 — visible to EVERY visitor since phase 55 task 03: the pill is + * static, always-visible markup with no reveal step, and the write + * surface is public — task 01): the "Share" pill (#share-chat-btn) + * turns the CURRENT conversation into a public read-only link + * (/shared/, a 128-bit uuid4 on the saved_chats row). The + * save-then-share contract: the same + * empty-conversation no-op guard as the auto-save (live region, no + * request); linked (currentChatId set) → POST * /api/chats//share (idempotent — the existing token comes back * unchanged); unlinked → POST /api/chats with { messages: conversation, * share: true } and link currentChatId to the created id — one action @@ -173,9 +183,15 @@ * input-like that selects its full URL on focus, .share-link-fallback) * and the live region reads "Share link ready — copy it from the * field." (the owner-locked fallback). Success (clipboard) reads - * "Share link copied." 403/5xx → the actionable error banner (signed- - * in hint, like Save); a network failure → the "is the app reachable?" - * banner. + * "Share link copied." Phase 55 task 04 (owner-locked A4): BOTH + * success paths ALSO raise the visual-only share toast (showToast — + * top-right slide-down, auto-dismiss ~4s, single instance; the node is + * aria-hidden, so the #send-status live region remains the sole a11y + * announcer — no double screen-reader read); a failed share NEVER + * toasts (the error banner is the failure UI). 403/5xx → the + * actionable error banner (neutral "try again" — with a public write + * surface a 403 is no longer a sign-in problem for a guest, phase 55 + * task 01); a network failure → the "is the app reachable?" banner. * * Stale saved chats (phase 53, TODO.md L4): every sync that changes the * knowledge base bumps the sources generation; a row saved against an @@ -192,16 +208,16 @@ * runTurn promise so the handler can await the turn's completion * (behavior-neutral for the existing Retry click, which ignores it). * Only when the turn completes WITHOUT the error banner does the handler - * persist the linked row through the SAME upsert as Save — PUT + * persist the linked row through the SAME upsert as the auto-save — PUT * /api/chats/ (the server re-stamps sources_version → stale: false); * a 404 (row deleted from History meanwhile) unlinks and recreates - * (saveCurrentChat's stale-link rule). A regenerate that errors + * (persistConversation's stale-link rule). A regenerate that errors * mid-stream leaves the row untouched (stale stays true); a regenerate * STOPPED mid-stream (phase 48) persists the stopped partial. Success * hides the banner and announces in the #send-status live region * (PLAN §7.4 never-stale). The banner also clears on "New chat" and on - * a successful manual re-Save (both make the row/conversation no longer - * the one the banner describes). + * a successful auto-save re-stamp (both make the row/conversation no + * longer the one the banner describes). * * All DOM ids match frontend/index.html. */ @@ -225,8 +241,9 @@ const sendStatus = document.querySelector("#send-status"); const banner = document.querySelector("#kb-banner"); const bannerText = document.querySelector("#kb-banner-text"); const versionEl = document.querySelector("#app-version"); -const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden) -const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden) +// Phase 55 (A2): the phase-50 #save-chat-btn query is GONE — there is no +// Save control; persistConversation() auto-saves headless at the save points. +const shareBtn = document.querySelector("#share-chat-btn"); // phase 55 task 03: Share pill — static markup, visible to every visitor const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden) const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill @@ -499,15 +516,15 @@ function openTuneForm(wrap, toggleBtn) { placeholder="e.g. be more concise — or: assume I'm on NixOS">`; const actions = document.createElement("div"); actions.className = "tune-form-actions"; - const saveBtn = document.createElement("button"); - saveBtn.type = "submit"; - saveBtn.className = "tune-save"; - saveBtn.textContent = "Save"; + const tuneSaveBtn = document.createElement("button"); + tuneSaveBtn.type = "submit"; + tuneSaveBtn.className = "tune-save"; + tuneSaveBtn.textContent = "Save"; const cancelBtn = document.createElement("button"); cancelBtn.type = "button"; cancelBtn.className = "tune-cancel"; cancelBtn.textContent = "Cancel"; - actions.append(saveBtn, cancelBtn); + actions.append(tuneSaveBtn, cancelBtn); form.appendChild(actions); const status = document.createElement("p"); status.className = "tune-error"; @@ -517,7 +534,7 @@ function openTuneForm(wrap, toggleBtn) { form.addEventListener("submit", async (e) => { e.preventDefault(); - saveBtn.disabled = true; + tuneSaveBtn.disabled = true; status.hidden = true; try { const r = await fetch("/api/steering", { @@ -537,7 +554,7 @@ function openTuneForm(wrap, toggleBtn) { } catch { /* non-JSON error body */ } status.textContent = detail; status.hidden = false; - saveBtn.disabled = false; + tuneSaveBtn.disabled = false; return; // form kept on failure — the instruction survives } const saved = document.createElement("p"); @@ -550,7 +567,7 @@ function openTuneForm(wrap, toggleBtn) { } catch { status.textContent = "Could not save the note — is the app reachable?"; status.hidden = false; - saveBtn.disabled = false; + tuneSaveBtn.disabled = false; } }); cancelBtn.addEventListener("click", () => { @@ -938,11 +955,16 @@ function appendMaybeTry(wrap, suggestions) { * * A durable LOCAL session (A10 unchanged: the API stays stateless — the * server stores nothing about the conversation). The whole conversation - * lives in localStorage under a versioned key; a format bump = clean start: + * lives in localStorage under a versioned key; a format bump = clean start. + * Phase 55 (A2): the shape extends IN PLACE with `chatId` — the + * saved_chats row link, so a reload restores the conversation AND its + * link (the same conversation never spawns a second row). A pre-55 + * record without the field reads as null (unlinked) — never throws: * - * bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text, - * sources?, deflected?, suggestions?, - * thinking?, tools?, stopped? }] } + * bor.chat.v1 → { v: 1, chatId: string | null, + * messages: [{ who: "user"|"brain", text, + * sources?, deflected?, suggestions?, + * thinking?, tools?, stopped? }] } * * Only RAW TEXT is stored — restore re-renders it through the escape-first * markdown renderer, so no HTML is ever persisted. Save points: the user @@ -952,7 +974,9 @@ function appendMaybeTry(wrap, suggestions) { * optional `stopped` marker; a pre-token stop persists nothing * brain-side), and the PARTIAL brain message on navigate-away (`pagehide`, * phase 20 — an in-flight turn keeps whatever had already streamed; - * thinking-only turns persist nothing brain-side). Every localStorage access + * thinking-only turns persist nothing brain-side). Phase 55 (A2): every + * save point ALSO auto-saves the row — persistConversation() (headless, + * quiet on failure, silent on success). Every localStorage access * is try/catch'd — private mode or quota exhaustion degrades silently to * in-memory-only chat. If the serialized state outgrows the budget (~700k * chars, far under the ~5MB quota) the oldest messages are dropped first. @@ -963,23 +987,32 @@ export const STORAGE_BUDGET_CHARS = 700_000; let conversation = []; // in-memory copy of the persisted messages -function loadStoredConversation() { +/* The stored record — { chatId, messages } or null. Phase 55 (A2): the + * row link rides the record so a reload restores it. `chatId` is + * OPTIONAL by contract (old-record safety): a pre-55 record without the + * field reads as null (unlinked) — never throws on the missing field. */ +function loadStoredRecord() { try { const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; + if (!raw) return null; const data = JSON.parse(raw); - if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return []; + if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return null; + const chatId = + typeof data.chatId === "string" && data.chatId.length ? data.chatId : null; // Legacy/corrupt shape → clean start; keep only well-formed raw-text // messages (nothing HTML-shaped can survive this filter). - return data.messages.filter( - (m) => - m && - (m.who === "user" || m.who === "brain") && - typeof m.text === "string" && - m.text.length > 0 - ); + return { + chatId, + messages: data.messages.filter( + (m) => + m && + (m.who === "user" || m.who === "brain") && + typeof m.text === "string" && + m.text.length > 0 + ), + }; } catch { - return []; // unreadable storage: start clean, never throw + return null; // unreadable storage: start clean, never throw } } @@ -988,7 +1021,7 @@ function trimToBudget(messages) { for (;;) { let size = Infinity; try { - size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length; + size = JSON.stringify({ v: STORAGE_VERSION, chatId: null, messages: out }).length; } catch { break; // even one message cannot serialize — keep it in memory only } @@ -1002,7 +1035,14 @@ function saveConversation() { try { localStorage.setItem( STORAGE_KEY, - JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) }) + JSON.stringify({ + v: STORAGE_VERSION, + // Phase 55 (A2): the row link is persisted with the record (null + // = unlinked) — a reload restores it, so the next save point + // updates the SAME row instead of spawning a duplicate. + chatId: currentChatId, + messages: trimToBudget(conversation), + }) ); } catch { /* quota/private mode: chat keeps working with in-memory state only */ @@ -1057,20 +1097,28 @@ function renderStoredMessage(m) { /* On load: re-render the stored conversation (markdown, source chips, deflected styling, maybe-try chips). addMessage hides the empty state, - so a restored conversation starts right where it was left. */ + so a restored conversation starts right where it was left. Phase 55 + (A2): the row link is hydrated from the record here, at boot — before + any save point can run — so the next message updates the SAME row + (a pre-55 record restores unlinked, exactly as phase 14 did). */ function restoreConversation() { - conversation = loadStoredConversation(); + const record = loadStoredRecord(); + conversation = record ? record.messages : []; + currentChatId = record ? record.chatId : null; // phase 55: the link survives reloads for (const m of conversation) renderStoredMessage(m); markLastRetryable(); // phase 49: the restored last brain bubble is retryable } -/* ---------- save & load saved chats (phase 50, owner-locked 2026-08-29) ---------- +/* ---------- saved-chats row link (phase 50; auto-saved since phase 55) ---------- * * `currentChatId` links the local conversation to a saved_chats row: - * set to the created row's id on a fresh Save, set to the opened id on a - * successful /?chat= boot load, cleared by "New chat" and by the - * 404-PUT fallback (the row vanished — recreate, never lose the save). - * null = unlinked (a plain local session, phase 14). + * set to the created row's id on a fresh auto-save (persistConversation + * — the first save point after "New chat"), set to the opened id on a + * successful /?chat= boot load, hydrated from the bor.chat.v1 + * record on the local restore (phase 55 — the link survives reloads), + * and cleared by "New chat" and by the 404-PUT fallback (the row + * vanished — recreate, never lose the save). null = unlinked (a plain + * local session, phase 14). */ let currentChatId = null; // string | null — the linked saved_chats row id @@ -1113,7 +1161,7 @@ async function restoreSavedChatFromUrl() { return unavailable(); // malformed body — treat as unavailable } // The API schema guarantees the record shape; the same defensive filter - // as loadStoredConversation keeps a corrupted stored row from poisoning + // as loadStoredRecord keeps a corrupted stored row from poisoning // the restore (nothing HTML-shaped, ever). const messages = (Array.isArray(data?.messages) ? data.messages : []).filter( (m) => @@ -1147,24 +1195,38 @@ async function restoreSavedChatFromUrl() { return true; } -/* Save the current conversation — the #save-chat-btn handler (phase 50). - * No-op with a live-region line when there is nothing to save. Upsert: - * linked → PUT /api/chats/ (re-Save updates the same row; no title in - * the body, so the row keeps its current one); unlinked → POST /api/chats - * (the server auto-titles) and link to the created id. A 404 from the PUT - * — the row was deleted on the History page — unlinks and retries as a - * create, then announces the outcome: the owner is never left with an - * unsaved conversation because of a stale link. 403/5xx/network → the - * error banner with an actionable line (the conversation is intact - * locally either way). Success is status text only — the #send-status - * live region, never stale (PLAN §7.4); no banner. */ -async function saveCurrentChat() { - if (!conversation.length) { - sendStatus.textContent = "Nothing to save yet."; - return; - } - if (saveBtn.disabled) return; // one save at a time (double-click guard) - saveBtn.disabled = true; +/* Auto-save the current conversation (phase 55, owner-locked A2) — the + * headless replacement of the phase-50 #save-chat-btn handler (the Save + * pill is gone: no button to press, no UI to update). Called fire-and- + * forget from the persistence save points (the user message on send, + * every brain-done through rememberBrainTurn, the pagehide partial — + * which rides rememberBrainTurn, so no extra wiring). The phase-50 + * upsert semantics, unchanged: + * + * • empty conversation → no-op (nothing to save, nothing to say); + * • linked (currentChatId set) → PUT /api/chats/ — the SAME row + * updates (no title in the body, so the row keeps its current one); + * a 404 from the PUT — the row was deleted on the History page — + * unlinks and retries as a create, so a stale link can never wedge + * the conversation; + * • unlinked → POST /api/chats (the server auto-titles) and link to + * the created id (201) — the first save point creates the row. + * + * The `persisting` flag is the double-fire guard: the save points can + * overlap (pagehide during a stream), so a call while an upsert is in + * flight is a no-op — the next save point retries. The A2 quiet contract + * on failure (non-ok HTTP or network): a one-line #send-status note, + * NO error banner, the turn never blocks. Success is SILENT (the History + * page is the visible proof — the toast is reserved for share), apart + * from clearing the phase-53 stale banner (a successful re-save + * re-stamps the row to the current generation — the row is no longer + * stale). */ +let persisting = false; // phase 55: one upsert at a time (double-fire guard) + +async function persistConversation() { + if (!conversation.length) return; // nothing to save + if (persisting) return; // an upsert is already in flight (double-fire guard) + persisting = true; const body = JSON.stringify({ messages: conversation }); const headers = { "Content-Type": "application/json" }; try { @@ -1181,23 +1243,27 @@ async function saveCurrentChat() { res = await fetch("/api/chats", { method: "POST", headers, body }); } if (!res.ok) { - showErrorBanner( - "Couldn't save the conversation — check you're still signed in and try again." - ); + // A2 quiet contract: a failed auto-save never blocks the + // conversation — one status-line note, no error banner, and the + // next save point retries. + sendStatus.textContent = + "Couldn't save automatically — will try on the next message."; return; } if (res.status === 201) { const created = await res.json(); - currentChatId = String(created.id); // fresh Save: link to the new row + currentChatId = String(created.id); // first save: link to the new row } - sendStatus.textContent = "Conversation saved."; - // Phase 53: a re-Save re-stamps the row to the current generation - // (task 03) — the row is no longer stale, so the banner is done. + // Silent on success (A2) — but a re-save re-stamps the row to the + // current generation (phase 53, task 03): the row is no longer + // stale, so the banner is done. if (staleBanner) staleBanner.hidden = true; } catch { - showErrorBanner("Couldn't save the conversation — is the app reachable?"); + // A2 quiet contract: a network failure is the same one-line note. + sendStatus.textContent = + "Couldn't save automatically — will try on the next message."; } finally { - saveBtn.disabled = false; // released on EVERY outcome — never stale + persisting = false; // released on EVERY outcome } } @@ -1250,6 +1316,41 @@ async function copyShareLinkWithFallback(absoluteUrl) { } } +/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE + * confirmation that a share worked. A4 owner-locked: the node is + * aria-hidden (visual only) — the #send-status live region remains the + * a11y announcer, so there is no double screen-reader read. Top-right, + * slides down, auto-dismisses in ~4s. A SINGLE instance: the node is + * lazy-created ONCE and reused — a new toast replaces a pending one + * (clear the prior dismiss timer, re-run the entry) and toasts never + * stack. The text lands via textContent only (XSS-safe). Shown on + * BOTH share-success paths; NEVER on a failure (the error + * banner is the failure UI). Page-script-local by design — the toast + * is chat-page only for this phase (no cross-page module). */ +let toastEl = null; // the single toast node — lazy-created, reused +let toastTimer = 0; // the pending auto-dismiss (replaced by a new toast) + +function showToast(message) { + if (!toastEl) { + toastEl = document.createElement("div"); + toastEl.className = "toast"; + toastEl.setAttribute("aria-hidden", "true"); // A4: visual only — #send-status is the announcer + document.body.appendChild(toastEl); + } + toastEl.textContent = message; // XSS-safe text assignment + // Re-trigger the entry even when a toast is already up (a second + // share while the first is showing): clear the pending dismiss, + // drop the visible state, force a reflow (restarts the CSS + // transition), then show again. + clearTimeout(toastTimer); + toastEl.classList.remove("is-visible"); + void toastEl.offsetWidth; // force reflow — the entry transition restarts + toastEl.classList.add("is-visible"); + toastTimer = setTimeout(() => { + toastEl.classList.remove("is-visible"); // auto-dismiss ~4s + }, 4000); +} + /* Share the current conversation — the #share-chat-btn handler * (phase 51, owner-locked 2026-08-29, TODO.md L6). No-op with a live- * region line when there is nothing to share (the same guard as @@ -1259,7 +1360,11 @@ async function copyShareLinkWithFallback(absoluteUrl) { * action saves AND shares (owner-locked). Success copies the absolute * URL (clipboard → inline-field fallback); the live region reads * "Share link copied." or "Share link ready — copy it from the - * field." 403/5xx → the actionable banner (signed-out hint); a network + * field." Phase 55 task 04: BOTH success paths additionally raise the + * visual-only toast (showToast — aria-hidden; the #send-status line + * stays the a11y announcer; a failed share NEVER toasts — the error + * banner is the failure UI). 403/5xx → the actionable banner + * (neutral — the write surface is public, phase 55 task 01); a network * failure → the reachable? banner. The double-click guard releases in * the finally — never stale (PLAN §7.4). */ async function shareCurrentChat() { @@ -1276,9 +1381,7 @@ async function shareCurrentChat() { // token comes back unchanged, a new one is minted. const res = await fetch(`/api/chats/${currentChatId}/share`, { method: "POST" }); if (!res.ok) { - showErrorBanner( - "Couldn't share the conversation — check you're still signed in and try again." - ); + showErrorBanner("Couldn't share the conversation — try again."); return; } shareUrl = (await res.json()).share_url; @@ -1291,9 +1394,7 @@ async function shareCurrentChat() { body: JSON.stringify({ messages: conversation, share: true }), }); if (!res.ok) { - showErrorBanner( - "Couldn't share the conversation — check you're still signed in and try again." - ); + showErrorBanner("Couldn't share the conversation — try again."); return; } const created = await res.json(); @@ -1301,9 +1402,19 @@ async function shareCurrentChat() { shareUrl = created.share_url; } const copied = await copyShareLinkWithFallback(absoluteShareUrl(shareUrl)); + // The #send-status lines stay the a11y announcer (PLAN §7.4 never- + // stale) — the toast below is visual only (aria-hidden, task 04). sendStatus.textContent = copied ? "Share link copied." : "Share link ready — copy it from the field."; + // Task 04 (A4): the VISIBLE confirmation rides the same two + // success paths, each with its own text. A failed share never + // toasts — the error banner is the failure UI. + if (copied) { + showToast("Share link copied."); + } else { + showToast("Share link ready — copy it from the field."); + } } catch { showErrorBanner("Couldn't share the conversation — is the app reachable?"); } finally { @@ -1319,11 +1430,12 @@ async function shareCurrentChat() { * lastBrainWrap, no preceding user record — make a stale or superseded * click a no-op that resolves nothing). The handler AWAITs the returned * turn promise, and only when the turn completed WITHOUT the error - * banner persists the linked row through the SAME upsert as Save: + * banner persists the linked row through the SAME upsert as the + * auto-save: * PUT /api/chats/ (the server re-stamps sources_version → the row * is fresh again); a 404 (the row was deleted from History meanwhile) - * follows saveCurrentChat's stale-link rule — unlink + recreate, so the - * owner is never left with an unsaved conversation. A regenerate that + * follows persistConversation's stale-link rule — unlink + recreate, + * so the owner is never left with an unsaved conversation. A regenerate that * errors mid-stream leaves the row untouched (stale stays true — * Regenerate stays available); a regenerate STOPPED mid-stream (phase * 48) persists the stopped partial (the owner engaged with the new @@ -1388,6 +1500,10 @@ async function regenerateStaleChat() { function rememberBrainTurn(rawText, meta) { conversation.push({ who: "brain", text: rawText || "…", ...meta }); saveConversation(); + // Phase 55 (A2): the auto-save rides the brain save point — the row + // updates with the new brain turn + metadata. The pagehide partial + // reuses this helper, so it rides the same path (no extra wiring). + persistConversation(); } /* ---------- new chat (phase 14) ---------- @@ -1430,7 +1546,7 @@ function applyAuthState() { function startNewChat() { if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return; conversation = []; - currentChatId = null; // phase 50: a new conversation is unlinked until saved + currentChatId = null; // phase 55: unlinked — a fresh row on its first message if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation clearStoredConversation(); removeTyping(); @@ -1564,6 +1680,10 @@ async function runTurn(text, { reask = false } = {}) { // sent, so a failed/interrupted turn never loses it. conversation.push({ who: "user", text }); saveConversation(); + // Phase 55 (A2): the auto-save rides the save point — an unlinked + // conversation creates its row here (auto-title, server-side), a + // linked one refreshes. Fire-and-forget: it never blocks the turn. + persistConversation(); } let wrap = null; @@ -1797,14 +1917,14 @@ input.addEventListener("keydown", (e) => { }); composer.addEventListener("submit", handleSend); -/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the Save pill stores - * the current conversation in Postgres (the upsert semantics live in - * saveCurrentChat). The button ships hidden in index.html; the boot - * IIFE below reveals it for admin (absent-not-hidden for anonymous, - * phase 16). Status-only feedback — the live region, never stale. */ -saveBtn?.addEventListener("click", saveCurrentChat); -/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill — - * same ship-hidden/reveal contract as Save (the boot IIFE below). */ +/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is + * GONE with the pill — there is no Save control; persistConversation() + * auto-saves headless at the save points (fire-and-forget, quiet on + * failure, silent on success). + * Phase 51 (owner-locked 2026-08-29, TODO.md L6; visible to every + * visitor since phase 55 task 03): the Share pill is static, + * always-visible markup (no reveal step) — only the click binding + * lives here. */ shareBtn?.addEventListener("click", shareCurrentChat); /* Phase 53 (task 05): the stale banner's Regenerate pill. The binding * is inert unless the banner is revealed — which only happens on the @@ -1839,15 +1959,18 @@ window.addEventListener("pagehide", () => { nav link; applyAuthState() then applies the chat-page-only gating. Phase 34: the steering panel's admin boot refresh (count badge) and the anonymous removal of the tuning surface both happen inside - initSharedHeader() now. */ + initSharedHeader() now. Phase 55 (A2): the local restore hydrates + currentChatId from the record (restoreConversation), so the row link + survives a plain reload — no Save pill to reveal anymore. */ (async () => { await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami applyAuthState(); // chat page: the auth pair (idempotent with header.js) - if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16) - if (shareBtn) shareBtn.hidden = !isAdmin; // phase 51: the Share pill joins the same block + // Phase 55 (task 03): no Share-reveal step — the pill is static, + // always-visible markup (visible to every visitor, phase 51 contract). // Phase 50: /?chat= (valid uuid + admin) boots into the saved - // conversation; every other outcome falls through to the local restore. + // conversation; every other outcome falls through to the local restore + // (which hydrates the row link from the record — phase 55). const openedSaved = await restoreSavedChatFromUrl(); if (!openedSaved) restoreConversation(); loadSuggestions(); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 8a1a36e..62a339a 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -308,44 +308,18 @@ html::after { whole control below 640px. */ .new-chat-btn svg { width: 16px; height: 16px; display: none; } -/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the "Save" pill — the - EXACT visual family of .new-chat-btn (same declarations, so the two - chat-shell actions always read as a pair): solid brand pill, --bg text - on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover lightens - the brand fill, focus-visible via the global 3px rule. Shipped hidden - in index.html — app.js reveals it for admin only (phase 16 - absent-not-hidden); ≤640px overrides below mirror the New chat ones - (label stays visible in .chat-shell, icon hidden). */ -.save-chat-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - min-height: 44px; - padding: 0.5rem 0.9rem; - border-radius: 999px; - border: 0; - background: var(--brand); - color: var(--bg); - font: inherit; - font-weight: 700; - font-size: 0.95rem; - white-space: nowrap; - cursor: pointer; -} -.save-chat-btn:hover { background: #f55a72; color: var(--bg); } -/* The save mark is hidden on desktop (the label carries the pill); it is - the whole control below 640px (mirrored in the ≤640 block below). */ -.save-chat-btn svg { width: 16px; height: 16px; display: none; } +/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save-pill rules + are GONE with the pill — every conversation auto-saves (app.js); + the chat-shell actions are New chat + Share only. -/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill — - the EXACT visual family of .save-chat-btn (same declarations, so the + Phase 51 (owner-locked 2026-08-29, TODO.md L6): the "Share" pill — + the EXACT visual family of .new-chat-btn (same declarations, so the chat-shell actions always read as a pair): solid brand pill, --bg text on --brand (5.2:1, WCAG AA), borderless, ≥44px target, hover lightens the brand fill, focus-visible via the global 3px rule. Shipped hidden in index.html — app.js reveals it for admin only - (phase 16 absent-not-hidden); ≤640px overrides below mirror the - Save ones (label stays visible in .chat-shell, icon hidden there). */ + (phase 16 absent-not-hidden); ≤640px overrides below mirror the New + chat ones (label stays visible in .chat-shell, icon hidden there). */ .share-chat-btn { display: inline-flex; align-items: center; @@ -430,6 +404,24 @@ html::after { flex: 1; } +/* Phase 55 (task 05, 2026-08-31, TODO.md L6, owner-locked A5): the + chat-actions row — New chat + Share share ONE .chat-actions element + (a normal .chat-shell column child). As a flex ITEM of the column it + spans the column width, but as a flex ROW with align-items: center + (not the column default stretch) each pill keeps its INTRINSIC + content width — two pills side by side, left-aligned in the column, + on desktop. The ≤640px block flips this to a vertical stack + (flex-direction: column + align-items: stretch — full-width pills, + New chat above Share; the existing ≤640px pill rules apply to the + stacked pills unchanged). The 46rem column contract is untouched + (PLAN §7). */ +.chat-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.6rem; +} + /* Phase 52 (owner revision 2026-08-30): `flex-grow` is the other half of the pin. `position: sticky` only ever pulls a box UP toward the viewport bottom — it can never push a box DOWN to meet it — so on an @@ -1304,6 +1296,49 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .stale-regenerate:disabled { opacity: 0.6; cursor: wait; } .stale-regenerate svg { width: 16px; height: 16px; display: block; } +/* Phase 55 (task 04): the share-success toast (TODO.md L5). Top-right, + just under the sticky header (--header-h + a small offset — the + variable already steps 64px → 58px at ≤640px), z-index 1000 (the + modal overlay contract — above the header's 20 and the skip-link's + 100). VISUAL ONLY (A4 owner-locked): the node ships aria-hidden + from app.js — #send-status stays the a11y announcer (no double + screen-reader read). Solid brand fill: --bg text on --brand = 5.2:1 + (AA — the .new-chat-btn family); rounded, shadowed, one line of + text that wraps under the small max-width. Hidden by default + (opacity 0 + pointer-events: none — it never intercepts clicks when + idle); .toast.is-visible (toggled by showToast) runs the entry: + slide-down + fade (translateY(-8px) → 0, 200ms). */ +.toast { + position: fixed; + top: calc(var(--header-h) + 0.75rem); + right: 1rem; + z-index: 1000; + max-width: min(22rem, calc(100vw - 2rem)); + padding: 0.6rem 1rem; + border-radius: var(--radius-sm); + background: var(--brand); + color: var(--bg); + font-size: 0.92rem; + font-weight: 600; + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + transform: translateY(-8px); + transition: opacity 200ms ease, transform 200ms ease; +} +.toast.is-visible { + opacity: 1; + transform: translateY(0); +} +@media (prefers-reduced-motion: reduce) { + /* No slide: BOTH states rest at transform: none (the .is-visible + rule would otherwise out-specify the bare .toast); the opacity + fade remains (120ms). */ + .toast, + .toast.is-visible { transform: none; } + .toast { transition: opacity 120ms ease; } +} + /* ---------- Login page (phase 16) ---------- */ /* Centered card in the standard frame: one admin, one password. */ .login-shell { @@ -2586,7 +2621,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } designated clip target, pills squeeze next). */ .nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; } .app-nav { gap: 0.15rem; } - .new-chat-btn, .save-chat-btn, .auth-link { padding: 0.45rem 0.5rem; } + .new-chat-btn, .auth-link { padding: 0.45rem 0.5rem; } } /* ---------- Responsive (mobile-first adjustments) ---------- */ @@ -2689,13 +2724,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } .new-chat-btn { padding: 0.4rem 0.3rem; } .new-chat-label { display: none; } .new-chat-btn svg { display: block; } - /* Phase 50: the Save pill squeezes with New chat (same family, same - rules — the chat-shell overrides below keep both labels visible). */ - .save-chat-btn { padding: 0.4rem 0.3rem; } - .save-chat-label { display: none; } - .save-chat-btn svg { display: block; } - /* Phase 51: the Share pill squeezes with Save (same family, same - rules — the chat-shell overrides below keep both labels visible). */ + /* Phase 51: the Share pill squeezes with New chat (same family, same + rules — the chat-shell override below keeps the label visible). */ .share-chat-btn { padding: 0.4rem 0.3rem; } .share-chat-label { display: none; } .share-chat-btn svg { display: block; } @@ -2703,10 +2733,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } hide the icon (the button lives inside .chat-shell, not the navbar). */ .chat-shell .new-chat-label { display: inline; } .chat-shell .new-chat-btn svg { display: none; } - .chat-shell .save-chat-label { display: inline; } - .chat-shell .save-chat-btn svg { display: none; } .chat-shell .share-chat-label { display: inline; } .chat-shell .share-chat-btn svg { display: none; } + /* Phase 55 (task 05, TODO.md L6, A5): the action row flips to a + vertical stack at the phone breakpoint — full-width pills, New + chat above Share. The pill rules above (padding, the + icon/label handling, the .chat-shell label overrides) apply to + the stacked pills unchanged. */ + .chat-actions { flex-direction: column; align-items: stretch; gap: 0.5rem; } /* Phase 53: the stale banner's row wraps at phone width (message above, action below) — the pill takes a full-width comfortable row instead of squeezing into the text. */ diff --git a/frontend/index.html b/frontend/index.html index 3e5487c..254b2d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -128,57 +128,62 @@

- - + - + Phase 14 (module-owned since phase 34 task 02): "New chat" + resets the local (localStorage) conversation and clears the + rendered list. The binding lives in header.js — dispatches + "bor:new-chat" which app.js acts on (it owns the + in-flight-turn guard + list reset). --> +
+ - - + migration 0009). The save-then-share contract: an UNSAVED + (unlinked) conversation is saved AND shared in ONE action — + app.js POSTs /api/chats with { messages, share: true } (the + server sets the token in the same commit) and links the + conversation to the created row; a saved (linked) one just + POSTs /api/chats//share (idempotent — the existing token + comes back unchanged). On success the ABSOLUTE link is + copied to the clipboard; a non-secure (http) homelab origin + that rejects the clipboard gets the inline link-field + fallback instead (owner-locked — app.js renders + .share-link-fallback near the status line). app.js binds the + click to shareCurrentChat. Unsharing lives on the History + page's Share column (admin-only). --> + +