c0ecf2fa3bebd46bdfd0169efd73b89653d1dfed
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ce6efdb3dc |
refactor(pending): one pending-op-owned preview for chat, /pending and flow views (#1537)
* refactor(pending): one pending-op-owned preview for chat, /pending and flow views A staged pending_operation was rendered three separate ways: the /pending page's OperationPreview switch (8 specialized renderers keyed on operation_type), ApprovalCard's own PreviewBlock (near-duplicate renderers keyed on 4 hardcoded MCP tool names), and AgentChat's toolNameFor() hack that mapped stored operation_types onto 'gnubok_'-prefixed tool names on hydration. This is the weakest seam ahead of flow-run views (plan seam 8.3): every new operation type had to be taught to render in two places and silently degraded in the third. Now there is one owner: - components/pending-operations/OperationPreview.tsx: the /pending renderers moved verbatim, dispatched on operation_type, consumed by /pending, ApprovalCard and future flow-run views. - components/pending-operations/vocabulary.ts: operation labels, single-action warnings and the one canonical rejection-category list (ApprovalCard's copy was byte-identical and is deleted). - lib/pending-operations/tool-name.ts: the single translation point between bare operation_types and 'gnubok_' tool names, with tests. toolNameFor gotcha fixed on the way: ApprovalCard's old dispatch only recognized 4 tool names, so a hydrated card for any other operation type (attach_document_to_transaction, match_transaction_invoice, ...) silently fell back to a raw generic preview. Hydration now passes the stored operation_type straight through attachStagedOperations to the card, and live streamed cards derive it from the event's tool name, so every operation type keeps its specialized preview on resume. Per-surface chrome (list row on /pending vs inline chat card) is deliberately kept: only the preview + vocabulary were the duplicated seam. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: drop a stray hunt_title copy rename that rode along 'Kvittojakten' -> 'Leta efter underlag' in messages/sv.json was uncommitted working-tree state from another session, swept into the extraction commit by git add breadth. It is a product-naming call with no en.json counterpart and does not belong in this refactor; preserved in this branch's first commit if it turns out to be wanted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pending): carry params to chat previews; guard preview amounts CodeRabbit round on #1537, both real. (1) AttachDocumentPreview renders its DocumentViewButton from params.document_id, which neither chat path carried: the staged_operation stream event now includes the tool-use input (the same values the staging tool stored as pending_operations.params) and hydration selects the params column, so an attach-document card in chat shows its evidence button live and on resume. (2) InvoicePreview and CreateTransactionPreview cast amounts straight into formatCurrency; a payload without one rendered 'NaN kr'. They now share the same show-the-gap guard the legacy summary already had. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1dce9227a4 |
feat(assistant): make the thumbs actually report something (#1236)
* feat(assistant): make the thumbs actually report something The thumbs up/down under an assistant answer shipped wired to nothing. They lit up, the vote died in component state, and the code said so in a comment nobody reading the UI could see. An affordance that looks like it reports something and does not is worse than no affordance: it spends the user's goodwill once, and silently. They now post to a new /api/agent/feedback, which emits the SAME agent.feedback event the gnubok_feedback MCP tool emits, with actorType 'user'. The product team already queries event_log for that type, so chat votes land in the backlog they read rather than in a second place someone has to remember to look at. event_log takes the payload as jsonb and already treats agent.* as telemetry, so there is no migration. The conversation id is caller-supplied, so it gets the same ownership check /api/agent/invoke got: without it a member could file feedback against a colleague's thread and the backlog would carry conversations the reporter never saw. Mutation-checked, three tests fail when the guard is removed. The pressed state is set only after the server accepts the vote, so the button never claims a report that never arrived, and a vote does not toggle off: it is append-only telemetry, and offering an undo we cannot honour would be a control that lies. Changing your mind sends the other sentiment instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): drop the unused free-text field from the feedback route compliance-swarm flagged the `comment` field as an undocumented PII exposure: free text embedded verbatim into the agent.feedback payload and written to event_log under telemetry retention, with no data-classification decision, next to a PostHog policy that treats the same class of data differently. The finding is right, and the field was worse than it looked: no caller ever sent one. The UI posts sentiment and a turn index. So this is dead API surface whose only effect was to accept whatever a user might type, in an accounting product, into a 180-day log: client names, personnummer, case details. Removed rather than documented. A comment box is a reasonable thing to want, but it needs its own classification and redaction decision made with the UI in front of it, not inherited from an unused parameter. The test now asserts the property instead of the field's absence: a caller that posts a comment anyway must not get it stored anywhere in the payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2da96c0be2 |
fix(agent): stop sending every page view to a third-party avatar CDN (#1226)
* fix(agent): stop sending every page view to a third-party avatar CDN Eight avatar SVGs were loaded from api.dicebear.com on every render. In an accounting product that meant every authenticated page view told a third party who was looking at it, from a domain we do not control, on the path of a logged-in surface. A firewalled or self-hosted install showed no faces at all. The SVGs are now generated once and served from public/agent-avatars. Each entry records the seed it came from, so the set can be regenerated reproducibly, and the command to do it is in the file. The licence question that made this look like a founder decision resolved itself on inspection: Notionists is by Zoish under CC0 1.0, public domain, no attribution required. Confirmed on dicebear.com/licenses and, more usefully, in each downloaded file's own RDF metadata, so the terms travel with the asset rather than living in a commit message. Tests pin the properties that matter rather than the file list: no entry may be a remote URL, every entry must have a file behind it, and no shipped SVG may carry an <image href>, a url(https://…), an xlink:href or a <script>, since self-hosting a file that then phones home would reintroduce exactly the request this removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(agent): assert the property, not a list of elements, for avatar externals The external-reference check enumerated <image href>, url(https://…) and xlink:href, which left <use href>, <feImage href> and scheme-relative //host through: exactly the requests the guard claims to prevent, via elements it happened not to list. That is how this sort of allowlist rots. It now strips the parts that legitimately carry URLs and are never fetched (the RDF metadata block, xmlns declarations) and then asserts that NOTHING in what remains points off-origin. Verified by injecting each of the four bypasses into a real asset and confirming the test fails on all of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4702a63cff |
fix(assistant): announce answers to screen readers, one label map, links that keep the thread (#1224)
* fix(assistant): announce answers to screen readers, one label map, links that keep the thread PR7 polish, three items from dev_docs/assistant_redesign_plan.md section 7. The chat had no live region at all. A screen-reader user got no signal that the assistant had answered: the reply simply appeared, for people who could see it. Announcement fires on turn boundaries rather than over the streaming text, because a live region on token deltas re-announces on every delta and makes the surface unusable; the finished answer is read once, capped, with a pointer to the message for the rest. Two intent-label maps had drifted. The panel opened on the bokslut wizard titled "Fråga Anna" while the same thread in the history list read "Hjälp med bokslut", and the list's fallback returned the intent id itself, putting "bokslut.step" in front of the user as the name of their own conversation. One map now, and an unknown intent can no longer fall through to its id. Links inside an answer were plain anchors, so following one did a full document load: the app rebooted and took the conversation with it, which is the opposite of what docking the panel was for. Internal links route client-side. External ones open in a new tab with rel="noopener noreferrer", since the href came out of a model that reads customer documents and target="_blank" without it hands the opened page a handle back into an authenticated session. Reduced motion needed nothing: globals.css already collapses every animation under prefers-reduced-motion, so per-class variants would be redundant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): review triage: scope the announcement to its own turn Six findings, all real. The announcement searched the whole thread, so a turn that produced no text of its own (tool-only, or an error) found the PREVIOUS answer and read it out as though it were new: a screen-reader user would hear a stale answer to a question that had just been asked. It now receives only the current turn's messages, bounded by an index captured when streaming starts. It also read an interrupted answer as a finished one. Stop leaves the partial text with a visible marker, so announcing it as the answer told a screen-reader user the opposite of what everyone else could see. messagesRef was assigned during render. React may replay a render, so the announcement could read a snapshot the user never saw; the write moved into an effect declared before the one that reads it. The 400-character cap applied to the preview only, so the appended continuation suffix pushed the real announcement past the limit the constant promised. The cap now covers the whole string, and the test asserts against the constant rather than a looser number the suffix could sneak past. INTENT_LABELS was a plain object literal, so intentLabel('toString') resolved Object.prototype.toString, passed the truthiness check and reached React as a conversation title. Null-prototype now. intent_id comes from the database. Markdown link titles were dropped: [text](url "title") carries a title that react-markdown passes through and the renderer ignored. Both new guards were mutation-checked: removing either makes its test fail. The turn-boundary index itself is component wiring, which this node-only unit project cannot exercise; announceableAnswer is tested against the slice it is given. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
60cc51fe21 |
feat(assistant): dock the panel into the frame, give the trigger a status channel (#1220)
* feat(assistant): dock the panel into the frame and give the trigger a status channel Two things the panel could not do. It covered the page it was talking about. Opening it on /invoices laid a 480px curtain over the invoice, so verifying an answer meant closing the thing that gave it. The page panel now gives up that width plus the frame's own gutter, and the two float side by side. Docking applies at the compact width only: expanded is a deliberate focus mode, where there is no page left to read anyway, so it goes back to overlaying. Driven by a --agent-dock-w custom property because the frame layout is a server component; globals.css seeds the default so the first paint is not a jump, and below md nothing changes. And a minimized session was silent. The agent could be three tool calls into a booking, or finished ten minutes ago, and the pill said "Fortsätt med Anna" either way, so the only way to find out was to reopen it. There is now one status channel: the trigger spins with the current step while work runs and shows an unread dot when a turn landed behind a hidden panel. The channel is a reducer in a React-free module rather than a pair of booleans, because a durable background run has to publish to the same one later. Its 'detached' state (working somewhere the user cannot see) is built and rendered now even though nothing dispatches it in v1, so adding runs is a publisher and not a redesign. Turn boundaries derive from the streaming flag rather than being published per call site: a turn can end by completing, erroring, aborting or being stopped, and missing one would leave the trigger claiming the agent is still working forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the Sonnet 5 ceiling, dock and status-channel decisions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a43a8b03cf |
refactor(assistant): one source of truth for the conversation list (#1214)
* refactor(assistant): one source of truth for the conversation list PR5 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): unified history. The two surfaces that list conversations had drifted apart in BEHAVIOUR, not just chrome. The in-sheet list rolled a failed rename back and said so; the /chat sidebar fired pin, archive and rename blind, with no res.ok check, no rollback and no message. A failed archive there removed a conversation from the list while it still existed on the server, and a failed rename displayed a title the server never saved, both until the next reload, with an unhandled promise rejection on a network error. State, search, grouping and all three mutations now live in one hook that both surfaces consume, so they cannot diverge again: every write is optimistic, reverts to the value captured before the write on failure, and reports it. The sheet gains pin and archive, which it never had. Archive resolves whether the row is really gone, so the sidebar only navigates away from a conversation that was actually archived. Chrome deliberately stays per-surface: a 320px sidebar that collapses to a rail and a sheet panel are different shapes, and merging the markup belongs with the shell work in PR6, where both containers change anyway. Verified: 9554 unit tests pass (5 new pinning the rollback semantics, including reverting to the original pin value rather than toggling and restoring a null title), lint and tsc clean on the touched files, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): test the real mutation code, and make rollback mutation-aware Review follow-ups on the unified conversation list. Both findings were right. The tests duplicated the state transforms and called fetch directly, so they never executed the hook: deleting the rollback entirely would have left them green. That is test theater. The transforms and the write coordinator now live in conversation-mutations.ts, React-free, and the tests exercise those. Checked by deleting the rollback and confirming three tests fail. Rollback was not mutation-aware. A failed archive restored a render-time snapshot of the whole list, discarding any pin, rename or archive made while the request was in flight; and a failing earlier write could roll back over a newer value for the same row (a double-click on pin). Writes now claim a per-row revision and only undo while they are still the latest for that row, and a failed archive re-inserts the single row into the list AS IT STANDS, at its server-sort position, rather than replacing the list. Also drops a ref read during render that the React lint rules reject. Verified: 9560 unit tests pass (11 covering the real coordinator, including the overlapping-write case and the concurrent-edit-survives-archive-failure case), lint clean, tsc clean, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(assistant): unstub globals so the fetch stub cannot outlive the file vi.restoreAllMocks does not undo vi.stubGlobal, and the config sets no unstubGlobals, so the stubbed fetch survived past the suite that set it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8397452440 |
feat(assistant): copy, feedback, an interrupt marker and a way back to the latest answer (#1213)
* feat(assistant): copy, feedback, an interrupt marker and a way back to the latest answer PR4 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): message anatomy and actions. Assistant turns get a hover action row: copy, thumbs up/down and the existing regenerate, which until now was the only affordance on an answer. gnubok_feedback exists as a tool with no UI at all, so the thumbs are local-only for the moment; the point of this row is that the affordances sit where people look for them, and wiring the vote through is a follow-up that cannot break reading an answer. Stop used to abort and leave the half-written answer looking finished, which is worst exactly when it stopped mid-figure. The partial text still stays, now with a marker saying it was interrupted. A failed send cleared the composer and left a user bubble that had never reached the server, so the question vanished on the next reload and had to be retyped. The text now goes back into the composer and the unsent bubble is dropped, so the screen matches what was actually sent. startTurn reports whether the request got out; a mid-stream failure still counts as sent and keeps its content. Autoscroll already respected a user who had scrolled up, but nothing told them an answer had landed below the fold. A "Nytt svar" pill now appears in that case and takes them back. Verified: 9549 unit tests pass (7 new pinning the stop and failed-send state rules), lint and tsc clean on the touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): anchor the jump pill to the message area, not the whole panel Self-review before merge: the pill sat at a fixed offset from the bottom of the component, but the composer below it grows to 128px as the user types. A long multi-line draft plus a scrolled-up reader would have slid the pill underneath the composer, exactly when it is needed. It now positions against the message area itself, so composer height is irrelevant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4de648fb5d |
fix(assistant): keep proposals, selections and picks intact across a resume (#1212)
* fix(assistant): keep proposals, selections and picks intact across a resume PR3 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7): resume fidelity. Four ways the chat lost state that the user had every reason to think was still there. Approval cards ride on streamed staged_operation events, which are never persisted, so reopening a conversation rendered the tool trace and the answer but silently dropped the card. The proposal then sat in Granskning for its full 30-day expiry with nothing in the thread pointing at it. run-turn already stamps agent_metadata.conversation_id on every staged row, so both resume paths (the sheet's history and the /chat page) now re-attach the still-pending ones to the last assistant turn. Regenerate abandoned whatever the discarded turn had staged: the card left the screen, the operation stayed pending, and the regenerated turn usually staged a second proposal for the same booking, leaving two live proposals for one action. It now withdraws them through the same reject path the Avslå button uses, so the audit trail records why they went away. The sheet's remount key ignored intentArgs while some callers pass a CONSTANT contextRef with varying args: bulk-book always uses 'inbox:bulk' and carries the selected ids. Selecting A+B, collapsing, then selecting C+D reopened the A+B conversation while the user believed C+D were being booked. The key now includes a stable serialization of the args. Picking conversation A (slow) then B (fast) let A's late response overwrite B, leaving the user typing into a thread they did not choose. A sequence token now means only the newest pick may write state. Verified: 9540 unit tests pass (11 new), lint and tsc clean on every touched file, guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: record the resume-fidelity decisions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): render hydrated proposals with the same preview as live ones pending_operations.operation_type stores the bare action name ('categorize_transaction'), while the streamed card carries the MCP tool name ('gnubok_categorize_transaction') and ApprovalCard's PreviewBlock dispatches on that. Hydrated cards therefore fell through to the flat generic preview instead of the journal-line one, so a resumed proposal looked materially worse than the same proposal did live: the opposite of what this PR is for. Found by checking the query against prod rather than trusting the mock, which is also how the stored value space was confirmed: categorize_transaction, create_voucher and approve_supplier_invoice are what exist in the wild, and the four operation types that have a specialized renderer all stage unprefixed. The test fixture now uses the real stored shape so the mapping is actually covered rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(assistant): await proposal withdrawals, surface staged-query errors, share the type Review follow-ups on the resume-fidelity batch. All four findings were valid. The withdrawals were fire-and-forget and raced the replacement turn, so the new turn could stage a second proposal before the old one was rejected: the exact double-staging this change exists to prevent. They are now awaited, a 409 counts as withdrawn (someone else resolved it, which is all we need), and if any withdrawal genuinely fails the turn stays on screen with an error rather than hiding a card whose operation is still pending. Both staged-operation loaders ignored their error result, so a database or policy failure rendered the conversation as successful with the proposals silently missing: again the failure this query exists to prevent, reintroduced through the error path. Both now propagate, matching the sibling message query. StoredStagedOperation now lives in @/types: it is a persisted API contract that crosses a server page and three components, not an AgentChat detail. The unserializable-args fallback used a timestamp, which collides for two objects created in the same millisecond and changes on every render tick for the same object, remounting the sheet mid-session. A WeakMap gives each object one stable id for its lifetime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |