Audited /dashboard, /dashboard/sites (+ detail), /dashboard/workforce/workers,
/dashboard/workforce/on-site, /dashboard/plant/vehicles (+ detail),
/dashboard/plant/equipment (+ detail), /dashboard/plant/fuel-logs,
/dashboard/financial/purchase-orders (+ detail),
/dashboard/financial/delivery-notes/inbox, /dashboard/financial/suppliers,
and /dashboard/chat at 375x812 and 768x1024 using a scroll-right test to
catch real horizontal page overflow (as opposed to a table's own,
intentional internal scroll).
Two repeating bug patterns accounted for every overflow found:
1. A page-specific `right={<div className="flex items-center gap-2">...}`
passed to Topbar can carry two or three labelled buttons (edit/import/
merge/back etc.). Without `flex-wrap` they don't fit a 375px screen in
one row and push the whole page sideways. Fixed on the vehicles,
equipment, and site detail pages, plus their list-page counterparts
(vehicles fuel-view, equipment plant-view) — all five instances of
this exact pattern. Same fix applied to a delivery-note card's button
cluster on the PO detail page, and to Topbar's own right-side
container as a general backstop.
2. `Intl.NumberFormat("en-ZA", ...)` joins currency/number groups with
non-breaking spaces, so a wide value like "R 24 988 663" can't wrap.
In a 3-up grid (site cards' Contract/Spent/Remaining) that overflowed
the column; fixed by stacking those to 1-up below `sm`. In a 2-up grid
(PO detail KPI tiles) added `min-w-0` so the tile clips instead of
growing the grid track past the viewport.
Also: the shared Dialog now caps at `max-h-[90dvh]` with internal
scroll (a dialog taller than the phone viewport had no way to reach
its close button or footer before), and the sidebar hamburger button
was overlapping the Topbar's title on every page below `lg` — reserved
space for it instead.
Tables already scroll horizontally inside their own card via the
shared `<Table>` component or an `overflow-auto` wrapper, and KPI
grids already collapse responsively — both were fine everywhere checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Desktop gets a collapse/expand toggle in the chat header
(ThreadSidebarToggle) that persists to localStorage via
useThreadSidebarCollapsed, a useSyncExternalStore-backed hook — chosen
over a useEffect+setState "read on mount" hook because that pattern
trips react-hooks/set-state-in-effect, and the error budget for this
branch has no room to add one.
Phones already hid the panel behind a slide-over drawer; the missing
piece was a header trigger that shows the current thread's title
(ThreadDrawerTrigger), and Esc-to-close alongside the existing
backdrop-tap/pick-a-thread close paths.
Both new components are intentionally small and separate from the
main file, since the PDF download button in the same file is being
worked on concurrently by another change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Topbar was already `sticky top-0`, which tracks the whole-page
scroll fine on both phones and desktop since neither the dashboard
layout nor any page wraps it in a clipping scroll container. What was
missing was the iOS PWA notch: add `paddingTop: env(safe-area-inset-top)`
so the bar's own background paints under the status bar/notch in
home-screen standalone mode instead of leaving a transparent gap.
z-30 stays below the z-40/z-50 used by drawers, dropdowns and dialogs,
and below the Leaflet map page's isolated stacking context (1760f77),
so the Topbar's dropdowns still float above the map and dialogs still
float above the Topbar.
Also fixes a real overlap bug surfaced while auditing at 375px: the
sidebar's fixed hamburger button sits at the same top-left corner as
the Topbar's title below `lg`, and nothing reserved room for it, so
the page title rendered partially underneath the button. Reserve that
space with padding instead, and truncate the title/subtitle so long
site names don't overflow the header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MINIMAX_BASE_URL already ends in /v1, so appending /v1/image_generation
hit a non-existent path and every image request failed. Also accept the
image_urls response shape by downloading the image.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ask-made PDF reports were shipping charts with axes but no bars/line:
html-to-image serializes the answer clone to a standalone SVG document
and rasterizes that, and never re-resolves CSS custom properties in
that step. Recharts sets fill/stroke straight to var(--series-1) etc.
as raw SVG attributes (report-chart.tsx's seriesColor), so an
unresolvable fill fell back to SVG's default (black — solid black
bars) and an unresolvable stroke fell back to "none" (an invisible
line), reproduced live with "Make me a PDF report on fuel at The Hills
for September".
bakeCssVariableColors walks the clone once it's attached to the
document with its light theme forced, and replaces every var(...)
fill/stroke attribute with its computed literal color before capture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ask_threads/ask_messages/ask_log shipped with created/updated typed as
plain `date` fields, which PocketBase never populates — every row's
created has been empty since. pb-add-ask-collections.py now emits
autodate fields (onCreate, plus onUpdate for updated) and migrates the
three live collections in place under --apply, re-adding
idx_ask_messages_thread_created if PocketBase drops it with the field.
--backfill (apply-only) just reports affected row counts: PocketBase
can only set an autodate field to *now*, never a historical value, so
there's nothing safe to backfill.
Since old rows keep an empty created either way, the read side sorts
ask_messages by `created,id` instead of `created` alone, so tied
(all-empty) rows land in a stable, deterministic order rather than
whatever order PocketBase happens to return them in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ask_log rows come back with created: "" — the collection's created/
updated fields are declared as plain date fields rather than PocketBase's
autodate type, so nothing populates them on create. That's a schema fix
(a human --apply step per Task 1's own rules, out of scope here). In the
meantime, the Change Requests page rendered new Date("").toLocaleDateString()
verbatim as the string "Invalid Date". Omit the line instead when the
date doesn't parse.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to 7773ac8: those tests build a bare new Request(), which
(unlike a real HTTP request) has no Host header unless one is set
explicitly. isSameOrigin now checks Host, so all three routes' tests
started failing every non-cross-site case. Default a Host header
derived from the test's own ORIGIN, and set it explicitly (mismatched
against Origin) in the cross-site case so that test still exercises a
real mismatch rather than a merely-missing header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live-testing the PDF report ("fuel at The Hills for September") showed
the same chart block rendered twice: once at the bottom of page 1,
again at the top of page 2. Every page drew the *entire* rendered
answer image, repositioned so its own break offset lined up with the
page's top — relying on the page's fixed height to crop away
everything else. That only works when a page's content run happens to
be exactly CONTENT_HEIGHT_PX tall; slicePages deliberately breaks
*earlier* to avoid cutting a block in half, so the leftover space at
the bottom of the shorter page bled into the next block's content
instead of staying blank.
Now each page gets its own canvas-cropped PNG sized to exactly the
content it should show (breaks[i] to breaks[i+1]), embedded as a
separate image — no reliance on page bounds to "crop".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Confirm/Reject/Undo were unusable from any host that didn't happen to
match Next's internally-reported request.url hostname: dev serves on
127.0.0.1 (or a LAN IP) but request.url came back normalised to
"localhost", so every genuine same-origin click failed with "Cross-
site request refused" (403) even though the browser's real Origin
header was correct. Compare Origin's host against the request's own
Host header instead, which always reflects what the client actually
sent.
Found live-testing the fixed-cost Confirm/Undo flow: PocketBase showed
the row was never actually blocked from being created by the guard
itself, but every apply/reject/undo click from the browser 403'd.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live-testing a free-query chart ("litres per vehicle per week") turned
up two bugs:
- query_records grouped rows in first-seen order, not calendar order.
Grouping by (vehicle, week) walks one vehicle's weeks at a time, so a
week bucket could appear more than once and out of order — and a
chart reads that row order straight onto its x-axis. Bucketed
results now always sort chronologically by the bucket field first;
anything the model sorts by still applies as a tie-breaker.
- make_chart accepted a non-numeric column (e.g. the "vehicle" label
column) as a series, silently producing a blank plot with no y-axis
since its cell values aren't numbers. It now checks actual cell
values and returns a clear error naming the numeric columns
available, so the model can correct itself instead of shipping an
empty chart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed tool call only ever reached the model as a short "Error: ..."
message so it could recover — but nothing was logged server-side, so
a real bug (as opposed to the model misusing a tool) left no trace to
debug from. Log the full error to the server console before handing
the short message back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Navigating away from /dashboard/chat and back (e.g. via the sidebar
nav, which drops the ?thread= query) reset the conversation to a
blank composer even though the thread was still live and listed in
the sidebar. Persist the actor's active thread id in localStorage and
restore it when the URL has no ?thread=, so a conversation survives
leaving the page as the plan specifies. "New thread" clears the
stored id so it still starts blank.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs/modules.md §7.1: threads, memory, files, free queries/virtual
datasets, the propose/confirm/apply/undo gateway, both PDF export paths
(manual answer export and Ask-made reports via make_pdf_report),
images, and change requests — with the actor/x-ask-user model and the
three new PocketBase collections.
docs/data-model.md §4.17: field-by-field ask_threads/ask_messages/
ask_log reference, cross-linked from modules.md via explicit anchors.
docs/api-reference.md §12/§12b: /api/chat's new body shape and its
thread/action/chart/image/pdf SSE events, plus every new agent-actions
route (picker, threads, attachments, confirm-card actions, memories,
change requests) and their auth-matrix entries; the pb-files proxy
entry now notes its ask_messages admin-token path.
README.md: the intro, the modules table (adds Change requests), and
the API surface summary now describe an agent that acts, not just a
read-only chat.
Also marks Tasks 1–12 and 9b's implementation checkbox done in the plan
doc (docs/superpowers/plans/2026-09-26-ask-agent-actions.md) — the
remaining unchecked box is 9b's live check, skipped because
ask_threads/ask_messages/ask_log aren't applied on the target
PocketBase yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
provider-anthropic.ts's model id is now overridable via ANTHROPIC_MODEL
(default unchanged: claude-opus-5, still the current model per the
claude-api skill — this was a configurability gap, not a stale model
id). docs/operations.md documents the new env var and a 5-prompt
comparison set for evaluating providers/models against each other.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Added mid-plan (docs/superpowers/plans/2026-09-26-ask-agent-actions.md,
new Task 9b — image generation from Task 9 is untouched): the user can
ask for any custom report and get a finished PDF, e.g. "give me a PDF
on fuel at The Hills for September", not just a manual export of
whatever is on screen (Task 8).
make_pdf_report({ title, subtitle?, filename? }) is the model's own
"this answer is a finished report" signal, called last once it has
already built the report (headings, narrative, KPI tables quoting tool
display strings, charts via make_chart). extractPdfMeta splits the
meta out the same way extractCharts/extractActionCard already do: the
model gets an ack, the browser gets the full meta via a new `event:
pdf`. The assistant's ask_messages row stores it in the existing
`parsed` json field, so reopening the thread still shows the report
cover and Download button — no schema change.
chat-view.tsx renders a report cover (wordmark, title, subtitle, date,
"Prepared by Ask for {name}") and a prominent "Download PDF · {title}"
button reusing Task 8's answerToPdf (same page-slicing, so a chart is
never cut across pages), and auto-downloads once per turn via
nextPdfTurnToDownload — a pure decision function, unit-tested directly
since this codebase's component tests use renderToStaticMarkup rather
than interactive DOM testing.
Live check skipped: needs a thread, which needs ask_threads (not yet
applied). Covered by unit tests (tool meta, the route's pdf event and
persistence, and the once-per-turn auto-download decision) instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Raises runChatLoop's default maxIterations from 8 to 16 (loop.ts) —
"read a file -> match -> propose" and free query/chart reports need
more round trips than a single report tool call; /api/chat needed no
change since it already relies on the loop's own default. The
system prompt's opening now introduces Ask as "the operations
assistant" that can measure, chart, read files, remember and propose
changes (not just answer questions), and a new "How to work" section
gives the five-step recipe (work out the ask, gather data cheaply,
propose for a change, lead-then-detail for a report, ask only when
genuinely ambiguous). The arithmetic ban, chart grammar and glossary
are untouched — asserted by existing and new tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
file_change_request (tools-actions.ts) logs an ask_log row
(type "request", status "open") for a change to the app itself — a new
page, column, button or report layout — distinct from propose_changes,
which changes data; the prompt now routes between the two.
list_change_requests (tools.ts, read-only) lists them, which the
existing MCP server picks up automatically since it iterates the whole
tools array. /dashboard/change-requests lists and re-statuses them
(open/planned/done/declined) via new GET/PATCH routes under
/api/ask/change-requests, linked from the sidebar under Ask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/lib/agent/images.ts's generateImage posts MiniMax image-01's
documented request shape (verified against the installed mmx-cli SDK:
POST {baseUrl}/v1/image_generation with
{model, prompt, aspect_ratio, n, response_format}) and reads
data.image_base64[0]. A base_resp.status_code of 1028/1030/2061 (the
same codes the CLI itself maps to a "needs Plus plan" hint for this
endpoint) surfaces as a clear, non-retried error rather than a generic
failure. generate_image (tools-actions.ts) stores the result as its own
append-only ask_messages row (kind: "image") and returns its url.
extractGeneratedImages splits a tool result the way extractCharts
already splits chart numbers — the browser gets the full image, the
model keeps only imagesAvailable: [{id}] so it can place
[[image:<id>]] without ever seeing pixels or a URL. answer-parts.ts's
splitAnswer now takes an imageIds set alongside chartIds (existing
chart-only callers keep working via a default empty set), and
AnswerMarkdown renders the image inline.
The pb-files proxy (src/app/api/pb-files/.../route.ts) now also serves
ask_messages (previously only delivery_notes/purchase_orders): since
that collection's rules are all null by design (Task 1), this one
collection mints a short-lived admin file token server-side rather
than exposing the underlying PB rule.
Live check skipped (both the chat flow and MiniMax call): reaching
generate_image needs a thread, which needs ask_threads (not yet
applied). Covered by unit tests against the documented request/response
shape and error codes instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/app/dashboard/chat/answer-pdf.ts: slicePages (the pure page-break
maths — packs consecutive top-level blocks under an A4 content height,
breaking only between blocks, an oversized single block gets its own
page rather than being split) and answerToPdf (clones the answer at a
fixed 794px/A4 width in light theme, renders it with html-to-image at
pixelRatio 2, and embeds one page per slice via pdf-lib with a header
and "Page n of N" footer). A new "PDF" button sits next to the
existing "Save as PDF" print button (relabelled "Print" for clarity —
it was always window.print()), filename
ask-<thread-title-slug>-<date>.pdf.
Live browser check skipped: reaching an answer at all now goes through
the picker -> threaded /api/chat flow, which needs ask_threads (not
yet applied). Covered by unit tests on the page-slicing maths and
slugify instead; the render/embed path itself is browser-only DOM code
that can't be meaningfully unit-tested under vitest's node environment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/lib/agent/people-match.ts's matchPeople matches on employee number
first, then normalised full name (case/accent/spacing/hyphen
insensitive, so "André" == "Andre" and "Nkosi-Dlamini" == "Nkosi
Dlamini"), then surname + first initial as "likely" — ambiguous
surname+initial pairs report "none" rather than guess. The
match_workers tool wraps it: typed names, or a previously-read
attachment's sheet (name/employee-no/id-number columns picked by
header text). The prompt's worker-loading recipe wires it to
read_attachment -> match_workers -> propose_changes, refusing to
re-create an exact match.
Live check skipped: needs the full chat pipeline (thread persistence),
which needs ask_threads (not yet applied). Covered by unit tests,
including Afrikaans/Zulu name variants and the ambiguous-match case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway (src/lib/agent/gateway.ts) is now the one seam every write
goes through: authorize() (NEVER_WRITABLE + dn_batches lock_*/stage),
planOps (schema validation via schema-cache.ts's new validateOp,
before-snapshots, $ref cross-checks, relation-id existence, the
sites/employees duplicate rule, capped at 500 ops), createPendingAction,
applyAction and undoAction. propose_changes is a registered tool;
apply/reject/undo are confirm-card routes under
/api/agent/actions/[id]/, same-origin only.
/api/chat now streams a full action card (`event: action`) while the
model itself only ever sees {actionId, status, problems} — the same
split extractCharts already does for chart numbers (extractActionCard).
The next user turn gets the outcome of the prior turn's action(s)
prepended to what the model reads (ChatEntry has no system role, so
this is the pragmatic equivalent of a system note). chat-view.tsx
renders action-card.tsx per assistant turn, hydrating from
GET /api/agent/actions/[id] when a thread reopens.
Deviations, both noted for the smallest-sensible-adjustment reason:
- Batches run as sequential individual PocketBase calls, not
pb.createBatch() — that's what makes $ref resolution exact rather
than dependent on the batch API's own (version-sensitive) request-
chaining syntax.
- Re-applying a "failed" action isn't specially deduped against ops
that already partially succeeded; only the single-attempt path is
implemented in this session.
Live check skipped: ask_threads/ask_messages/ask_log still don't exist
in the target PocketBase (Task 1's --apply is the user's own step).
Covered by mocked unit tests instead — 17 gateway tests plus the
confirm-card routes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the generic query engine: src/lib/agent/schema-cache.ts (live PB
collection schema, 5-min cache), src/lib/agent/query.ts (QuerySpec zod
schema, runQuery with pb.filter-bound where clauses, Monday-start week
bucketing, sum/avg/min/max/count/count_distinct aggregation, a 20000-row
PB cap, and a 2h/200-entry result cache), and src/lib/agent/datasets.ts
(seven virtual datasets — site_summary, fuel_log_status, plant_hours,
payroll_hours, attendance, supplier_balance, po_fulfilment — each built
from the same src/lib/metrics computations the dashboard pages use;
plant_hours totals are tested against summarisePlantMeter on a shared
fixture). query_records and make_chart are now registered tools.
Adjustment: attendance is today-only (groupAttendance, the only
existing pure function for this, has no other granularity — a true
per-worker-per-day history would need new computation this task
doesn't otherwise call for).
Live check skipped: reaching query_records/make_chart through /api/chat
needs a thread, which needs ask_threads (not yet applied). Covered by
unit tests (including the plant_hours/page-aggregate parity check)
instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds src/lib/agent/attachments.ts (parseAttachment/previewForModel/
summarise for xlsx, xls, csv, pdf — text-layer or scanned, via
pdftotext — image and plain text) with fixtures under
src/lib/agent/__fixtures__/, POST /api/chat/attachments (multipart
upload into an append-only ask_messages row, rasterising a scanned
PDF's first pages so they ride along as vision images the same way a
photographed slip does), and the read_attachment / send_to_po_pipeline
tools in tools-actions.ts. send_to_po_pipeline mirrors
/api/purchase-orders/upload's own hash-dedupe-then-create logic
directly against PocketBase rather than a same-process HTTP call (no
reliable base URL to call itself with from a server-side tool handler).
chat-view.tsx gained a second attach control (FileUp icon) alongside
the existing PO-upload paperclip, wired to the new route; pending
attachment chips ride along on the next message the same way pending
photos already do.
Deviation: send_to_po_pipeline takes { id, site } (the plan's { id }
alone can't resolve which site's purchase_orders row to create,
since /api/purchase-orders/upload always required one).
Live browser check skipped: ask_messages doesn't exist yet in the
target PocketBase (pending the user's --apply of Task 1's schema).
Covered by mocked unit tests and real fixture files instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds src/lib/agent/memory.ts (remember/forget/listMemories, dedupe by
normalised text, formatMemoriesForPrompt) and wires it in: a
"## What you remember" system-prompt section, the remember/forget/
list_memories tools (src/lib/agent/tools-actions.ts, a per-request
factory since these need the actor unlike the static read-only tool
set), a REST view for the UI (/api/ask/memories), and a Memory drawer
in chat-view.tsx (Mine / Shared with everyone, add, delete).
Adjusted src/lib/agent/read-only.test.ts: its blanket "no mutation
calls under src/lib/agent" contract now allowlists the specific write
seams the plan designates (memory.ts here; tools-actions.ts, gateway.ts
and images.ts to follow) instead of forbidding every file, since
memories/requests are specified to bypass the gateway and write
directly.
Live browser check skipped: ask_log doesn't exist yet in the target
PocketBase (pending the user's --apply of Task 1's schema). Covered by
mocked unit tests instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the "Who's using Ask?" picker (GET /api/ask/users), actor
resolution via x-ask-user (src/lib/agent/context.ts), and thread
persistence (src/lib/chat/threads.ts, /api/chat/threads routes).
/api/chat now accepts { threadId?, message, display?, images? } and
persists each turn as its own append-only ask_messages row; the old
{ history } body still works for one release. chat-view.tsx gained the
picker, a thread sidebar (new/select/rename/archive), and ?thread=<id>
in the URL.
Live browser check skipped: ask_threads/ask_messages/ask_log don't
exist yet in the target PocketBase (Task 1's schema is dry-run only,
pending the user's --apply). Covered by mocked unit tests instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Additive, dry-run-by-default script creating ask_threads, ask_messages
and ask_log (change/memory/request), following the
pb-add-dn-staged-ingestion.py pattern. --dry-run confirms all three
collections are new; --apply is a human step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Green when a supplier's ordered > 0 and outstanding === 0 -- the whole
row, not just the number, so a fully-paid-up supplier stands out at a
glance.
Period filter next to the search:
All time, This week, This month,
Week 1-4 (of current calendar month, last absorbs any remainder),
Month 1 (current) / Month 2 (last) / Month 3 (month before),
Custom range with two date inputs.
Window anchors on delivery_notes.date_received -- liability accrues when
the goods arrive, not when the PO is uploaded ('we only pay what we
received this month'). computeSupplierBalances gains lastDeliveryISO per
row; a pure helper in supplier-period.ts resolves windows in UTC so the
boundary does not drift with the server's timezone.
Four KPI tiles at the top of the page:
- Total ordered / delivered / outstanding (cumulative, always).
- Payable this period (delivery value inside the active window).
Subtitle is honest: 'subtract anything already paid' -- we do not
track supplier payments, so this is the upper bound.
ui/select.tsx exports a SelectLabel so the period dropdown can group
options.
The query string skipped the PIN gate on any URL. The PIN is now the
only way in. Docs updated; the login plan notes there is no fixed
cutover date while the app is in on-site testing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A live /findings run showed the model adding per-site and per-item
counts itself. Rules that span sites now return one finding with the
total in the title and the per-site split in the detail, and the prompt
says not to add counts across findings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
list_sites now reads computeSiteSummaries (moved verbatim, output pinned
by snapshot). The read-only guard also scans src/lib/reports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>