Work on the chat interface

Goal: change the chat surface without discovering the four Nextcloud constraints one crash at a time.

The chat is a React root mounted inside a page Nextcloud rendered — not an SPA, not an iframe. Layout, build output and file locations are in Reference: frontend.

The loop

cd tamebi-nc-app/frontend
npm run dev     # vite build --watch into ../js/dist

The app directory is bind-mounted into Nextcloud, so a rebuilt bundle is live on reload. Two exceptions where the bind mount is not enough:

  • Changing a PHP annotation or a constructor signature needs docker compose restart nextcloud. Annotations are opcache'd, and you will otherwise debug code that is not the code running.
  • Changing the OnlyOffice plugin needs docker compose restart onlyoffice, and its .gz siblings are what actually get served — regenerate them.

Four constraints that bite

Register assets from PHP, never from the template. Nextcloud's CSP pairs a nonce with strict-dynamic, so a hand-written <script src> in a template is blocked outright. Assets are declared in the controller.

Build filenames are fixed, with no content hash, so the template can link them by name; Nextcloud's own cachebuster handles invalidation. This is also why the documentation site is a separate Vite project — a second entry point would fight that config.

Server-side values arrive as data attributes on the mount node. URL generation stays server-side, so a subdirectory install keeps working and escaping happens once.

Utilities need !important. The CSS entry uses @import "tailwindcss" important; — Nextcloud's own stylesheets are specific enough to win otherwise.

Do not let the chat instance be rebuilt

const chat = useMemo(() => new Chat<UIMessage>({}), [apiUrl]);
const { messages, sendMessage, status, error } = useChat<UIMessage>({ chat });

The hook does not re-expose the method that answers an approval — its public surface stops at sending, regenerating, stopping and reading messages. Answering an approval is the entire point of the confirmation mechanism, so the instance is kept and used directly.

Two related traps, both silent:

  • The ai version must match the one @ai-sdk/react bundles. A nested copy in node_modules means they have drifted, and approvals are discarded without an error.
  • Answering an approval updates local state and sends nothing unless the automatic-send condition for completed approval responses is configured.

Read the CSRF token at call time, not once at startup: Nextcloud rotates it.

Rendering a message

A message is a list of parts, not a string. Text parts render as markdown; tool parts render as a trace — with exactly one exception:

if (p.approval && p.approval.approved === undefined) {
  return <ApprovalRequest part={p} onDecide={} pending={deciding} />;
}
return <ToolCall part={p} />;

An unanswered approval is the only thing the user must act on. Everything else is trace they can ignore.

MCP tools arrive as dynamic-tool parts, not tool-<name> — they are not statically known to the client. Match both, or your new tool renders as nothing.

The trace maps tool names to short human sentences — "Reading a file", "Adding a calendar event" — and shows the single argument worth showing. An unknown tool falls back to its name with underscores replaced, so a new capability is readable before anyone writes a label for it.

Adding an AI-facing component

Check the AI Elements registry before writing one. Streaming states, message shapes and tool displays are already there, and the catalogue is authoritative against the registry rather than from memory:

curl -s https://registry.ai-sdk.dev/registry.json
npx shadcn@latest add @ai-elements/<name>

Then restyle it with the design tokens rather than accepting the default look. Do not fork a component to change its appearance, and do not hand-roll a chat input, a message list, a streaming indicator or a tool-call display — every one of those is in the catalogue.

Error messages: keep the allow-list

This is the part most likely to be undone by accident.

The island renders a server-supplied message only when the error envelope carries a request id, which is the signature of an error this project produced. Everything else gets a message from a small map.

The first version was a deny-list — a regex hunting for technical-looking strings — and it missed CSRF check failed, which Nextcloud's own middleware returns before our controller ever runs. A deny-list will always miss one.

What it replaced said: "The orchestrator answered 412. Try again, or check that the orchestrator service is running." Two failures in one sentence — it named a service the user has never heard of, and it was wrong, because 412 is Nextcloud rejecting a stale token.

The reference id is shown, not hidden. It is the only way a user can help find the matching log line, and it identifies a request, not a person.

Styling

DESIGN.md is the single source for colours, type, spacing, radii and motion. Every token is a CSS variable in the one entry file where Tailwind is configured, consumed only through Tailwind classes — bg-surface-dark, text-on-dark-soft, font-display.

No hardcoded values, no Tailwind default palette, no component defining its own colours. A genuinely missing value is added to DESIGN.md first.

Testing

npm test
npm test -- --test-name-pattern='<name>'

Logic that can be tested without a DOM lives in src/lib/. For anything visual, run the app — a screenshot is worth more than an assertion about a class name.