-
Notifications
You must be signed in to change notification settings - Fork 427
Wes/core architecture client rework #2749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bwreid
wants to merge
16
commits into
main
Choose a base branch
from
wes/core-architecture-client-rework
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
4fb0143
feat: add Signature, Image, and Video docs components
bwreid 7f46bbb
feat: add hover-to-expand popup to the Image docs component
bwreid ed34c3b
feat: polish the Signature docs component's title and expanded border
bwreid 19c4d1d
docs: add Client Overview draft page
bwreid 5e858f9
docs: add Data & Sync draft page
bwreid 097a117
docs: add Agent Chat draft page
bwreid 3884ad3
docs: add Routing draft page
bwreid e7df922
docs: fix ordered list numbering in Agent Chat draft
bwreid 832bfef
docs: split client-advanced into Advanced, Sync Internals, and Entry …
bwreid 51cdac3
docs: add Signature components to Data & Sync
bwreid 992b26f
docs: fix review feedback on client docs and docs components
bwreid 509cd86
docs: un-draft Client section, remove legacy pages, translate into al…
bwreid ed61a8d
Merge remote-tracking branch 'origin/main' into wes/core-architecture…
bwreid f356e58
docs: clear guard:i18n-catalogs findings on new Client docs blocks
bwreid a5a3ee4
docs: add writing-reference-docs skill
bwreid 8eba6d8
fix: docs build-blocking CSS brace and locale-aware navigation
bwreid File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| --- | ||
| name: writing-reference-docs | ||
| description: >- | ||
| How to write a function/hook/action reference section: plain-language | ||
| signature, real arguments, escalating examples grounded in a real app, | ||
| trimmed prose with no em dashes or semicolons. Use when writing or editing a | ||
| packages/core/docs/content reference page. | ||
| scope: dev | ||
| metadata: | ||
| internal: true | ||
| --- | ||
|
|
||
| # Writing Reference Docs | ||
|
|
||
| This came out of rewriting `client-data.mdx`'s hook sections | ||
| (`useActionQuery`, `useActionMutation`, `callAction`, `useDbSync`) section by | ||
| section with the user. It captures the shape that emerged so the next | ||
| reference section starts from it instead of reinventing it. | ||
|
|
||
| ## Rule | ||
|
|
||
| Document each function, hook, or action reference as: a plain-language | ||
| purpose statement, a bulleted argument list matching the real signature, two | ||
| or more escalating runnable examples grounded in a real app, and a one or two | ||
| sentence closing behavior note. Never a bare one-line description with a | ||
| single toy snippet. | ||
|
|
||
| ## Why | ||
|
|
||
| The original `client-data.mdx` gave each hook one sentence and one minimal | ||
| snippet with no options argument shown at all. That hid real, common needs | ||
| (conditional fetching via `enabled`, a post-mutation side effect via | ||
| `onSuccess`) that readers would only discover by reading the source. It also | ||
| leaned on a hypothetical `leads` domain (`get-lead`, `create-lead`, | ||
| `archive-lead`) that reads as unconvincing next to an example grounded in a | ||
| real app with a real schema and real access rules. | ||
|
|
||
| ## How | ||
|
|
||
| 1. **Match heading level to role.** Hooks that solve the same kind of problem | ||
| get grouped under one parent heading (e.g. `### Action hooks`) with each | ||
| hook as a child heading below it. A utility that is not a hook (like | ||
| `callAction`) is a sibling heading at the same level as the group, not a | ||
| child of it, even if it lives right next to the group. | ||
| 2. **Open with plain-language purpose, then link out.** State what the | ||
| function is for in one or two sentences ("This hook is intended to be used | ||
| for..."). If it wraps another library's hook (React Query's `useQuery`, | ||
| `useMutation`), link its reference page instead of re-documenting fields | ||
| you don't own. | ||
| 3. **List real arguments as a short bullet list**, matching the actual | ||
| exported signature in `packages/core/src/client/`, not a paraphrase. Read | ||
| the source before writing the list. Skip the bullet-list format entirely | ||
| for a function that takes only one argument. Fold that into a sentence | ||
| instead: | ||
|
|
||
| ```md | ||
| `useActionQuery()` accepts three arguments: | ||
|
|
||
| - **actionName**: the action's registered name. | ||
| - **params**: the action's input, typed from its `defineAction()` schema. | ||
| - **options**: everything from the [useQuery options](...) except | ||
| `queryKey` and `queryFn`, which the hook sets itself. | ||
| ``` | ||
|
|
||
| 4. **Give two or more escalating examples.** The first is the simplest | ||
| possible call. The second demonstrates one real, common option (a | ||
| conditional fetch, a success callback, a longer timeout), with one | ||
| sentence before it explaining what's different and why it matters. | ||
| 5. **Ground every example in a real reference app's real action**, not an | ||
| invented one, e.g. `get-ticket`, `send-ticket-reply`, `update-ticket` from | ||
| a real ticket-support example app, rather than `get-lead`/`create-lead`. | ||
| Exception: if the page already threads a hypothetical domain across | ||
| multiple sibling pages (a running example), keep using that domain in this | ||
| page too. Enrich its snippets with the missing option; don't swap the | ||
| domain out from under the other pages. | ||
| 6. **Close with the one behavior fact a reader needs**, in one or two | ||
| sentences: cache key, invalidation trigger, timeout. | ||
| 7. **Prose rules, every sentence:** no em dashes, no semicolons (this | ||
| includes table cells), short sentences. Split into two sentences instead | ||
| of joining with either. Reference every function by name with parens, | ||
| `useActionQuery()` not `useActionQuery`. | ||
| 8. **Example values must mean something to a reader with zero app context.** | ||
| Don't reuse an in-app sentinel value (like a `"me"` string a real action | ||
| resolves specially) without explaining it. Use a literal, self-explanatory | ||
| value instead, like a real-looking email address. | ||
|
|
||
| ## Don't | ||
|
|
||
| - Don't introduce a new framework concept into the primary example just | ||
| because it's technically correct for that hook. If most readers of this | ||
| page will never need to write it themselves (e.g. wiring `ignoreSource` with | ||
| a per-tab id), it belongs in the options table, not the main example, or on | ||
| a deeper page like Advanced. | ||
| - Don't swap a page's running example domain without checking whether sibling | ||
| pages share it. Grep the other draft pages for the same domain terms first. | ||
| - Don't leave a semicolon or em dash anywhere in the page, including table | ||
| cells. Split into two sentences. | ||
| - Don't add a one-item options bullet list for a function that only takes a | ||
| single argument. State it in a sentence instead. | ||
| - Don't paraphrase a signature from memory. Grep the actual export in | ||
| `packages/core/src/client/` and read its real parameter and option types. | ||
|
|
||
| ## Related Skills | ||
|
|
||
| - **writing-agent-instructions** — the sibling guide for AGENTS.md/SKILL.md | ||
| prose, which this borrows its "say it once, plainly" spirit from. | ||
| - **agent-native-docs** — how to look up the version-matched docs this skill | ||
| helps you write. | ||
| - **internationalization** — localized copies under `content/locales/*` need | ||
| the same edit when a source doc's meaning changes (see CLAUDE.md). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| --- | ||
| title: "Advanced" | ||
| description: "Lower-level app/ primitives and escape hatches: direct application-state access, background chat sends, custom suggestion chips, and the MCP App host bridge." | ||
| --- | ||
|
|
||
| # Advanced | ||
|
|
||
| Overview, Data & Sync, Agent Chat, and Routing cover the typical case: the hooks and helpers most pages need. This page covers the lower-level primitives and narrower escape hatches underneath them. Most apps never touch these directly. Reach for one when you're building something more custom than the default hooks support, such as a custom selection sync or an embedded MCP App. For syncing a raw `useQuery` with a hand-rolled key, see [Sync Internals](/docs/client-sync-internals) instead. | ||
|
|
||
| ## Application State {#application-state} | ||
|
|
||
| `application_state` is the SQL-backed key/value store the agent reads to know what the UI is currently looking at, including navigation, selection, and other focused-object context (see [Context Awareness](/docs/context-awareness)). `useAgentRouteState` and `useSemanticNavigationState` (also covered in Context Awareness) already wrap these for the common navigation case. Reach for the primitives below directly when you're syncing something they don't cover, such as a custom selection, or writing from outside a component's render. | ||
|
|
||
| ### readClientAppState {#read-client-app-state} | ||
|
|
||
| Read the current value for a key. Resolves to whatever was last written for that key, or `null` if the key has never been written. | ||
|
|
||
| ```ts | ||
| import { readClientAppState } from "@agent-native/core/client/hooks"; | ||
|
|
||
| const selection = await readClientAppState("selection"); | ||
| // selection is exactly what was last written to that key, e.g.: | ||
| // { kind: "tickets.rows", ids: ["tik_182", "tik_209"] } | ||
| // | ||
| // or, if nothing has written "selection" yet: | ||
| // null | ||
| ``` | ||
|
|
||
| ### writeClientAppState {#write-client-app-state} | ||
|
|
||
| Write a value for a key. Unlike `setClientAppState` below, it always writes, even if you pass `null`. Passing `undefined` throws instead, since `undefined` isn't valid JSON. Use `setClientAppState(key, undefined)` or `deleteClientAppState()` to clear a key. | ||
|
|
||
| ```ts | ||
| import { writeClientAppState } from "@agent-native/core/client/hooks"; | ||
|
|
||
| await writeClientAppState( | ||
| "selection", | ||
| { id: recordId }, | ||
| { requestSource: TAB_ID }, | ||
| ); | ||
| ``` | ||
|
|
||
| ### setClientAppState {#set-client-app-state} | ||
|
|
||
| The convenience wrapper most UI code should reach for: writes the value, or deletes the key when you pass `null`/`undefined`. Use this instead of `writeClientAppState` unless a call site genuinely needs "always write, never delete." | ||
|
|
||
| ```ts | ||
| import { setClientAppState } from "@agent-native/core/client/hooks"; | ||
|
|
||
| await setClientAppState( | ||
| "selection", | ||
| { id: recordId }, | ||
| { requestSource: TAB_ID }, | ||
| ); | ||
| await setClientAppState("selection", null); // clears the key, same as deleteClientAppState | ||
| ``` | ||
|
|
||
| ### deleteClientAppState {#delete-client-app-state} | ||
|
|
||
| Remove a key entirely. | ||
|
|
||
| ```ts | ||
| import { deleteClientAppState } from "@agent-native/core/client/hooks"; | ||
|
|
||
| await deleteClientAppState("pending-selection-context"); | ||
| ``` | ||
|
|
||
| ### readClientAppStateMany {#read-client-app-state-many} | ||
|
|
||
| Read several keys in one request. Returns `{ values, missing }`. `values` holds an entry for every key the server has a row for, including a key that was explicitly written as `null`. `missing` lists the keys that have never been written at all. This is how "never written" stays distinguishable from "written as null." | ||
|
|
||
| ```ts | ||
| import { readClientAppStateMany } from "@agent-native/core/client/hooks"; | ||
|
|
||
| const { values, missing } = await readClientAppStateMany([ | ||
| "selection", | ||
| "pending-selection-context", | ||
| ]); | ||
| ``` | ||
|
|
||
| Requests over 100 keys are chunked automatically. `readClientAppState()` itself is built on this: calls issued in the same tick are coalesced into one batched request behind the scenes, so calling it many times in a single render pass costs one round trip, not one per call. | ||
|
|
||
| ### Options {#application-state-options} | ||
|
|
||
| `signal` is accepted by all five functions above, to cancel an in-flight call. `requestSource` and `keepalive` only apply to the three write functions, since they don't mean anything for a read. | ||
|
|
||
| | Option | Type | Applies to | Description | | ||
| | --------------- | -------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | ||
| | `requestSource` | `string?` | `writeClientAppState`, `setClientAppState`, `deleteClientAppState` | Tags the write with an id so a matching `useDbSync({ ignoreSource })` can skip refetching it | | ||
| | `keepalive` | `boolean?` | `writeClientAppState`, `setClientAppState`, `deleteClientAppState` | Lets the request outlive page unload. Use for cleanup writes like clearing selection | | ||
| | `signal` | `AbortSignal?` | all five functions | Cancels the request | | ||
|
|
||
| ### Failure behavior {#failure-behavior} | ||
|
|
||
| All five functions throw on a failed request, a non-2xx response, or a body that can't be parsed. The thrown `Error` carries a `.status` property when the server responded, so a caller can branch on the HTTP status if needed. Wrap a call in `try`/`catch`, or let it propagate to an error boundary, the same as any other network call. | ||
|
|
||
| ### What TAB_ID is {#tab-id} | ||
|
|
||
| `requestSource` is commonly set to `TAB_ID`, a plain constant every template exports from `app/lib/tab-id.ts`: a short random string that stays the same for the life of a tab and differs between tabs. Pairing `requestSource: TAB_ID` on a write with `useDbSync({ ignoreSource: TAB_ID })` on the read side is how a tab avoids refetching its own write while still reacting to everyone else. See [Context Awareness](/docs/context-awareness#jitter-prevention) for the full walkthrough. | ||
|
|
||
| ## sendToAgentChat: Background Sends {#background-send} | ||
|
|
||
| Use `background: true` when a UI action should kick off real agent work without | ||
| opening or focusing the sidebar. This still creates a normal chat thread/run, | ||
| uses the agent's tools/actions/context, and keeps the work observable through | ||
| the runs tray. It is not a raw one-shot model call. | ||
|
|
||
| ```ts | ||
| import { sendToAgentChat } from "@agent-native/core/client/agent-chat"; | ||
|
|
||
| const tabId = sendToAgentChat({ | ||
| message: "Analyze this import and create any missing records", | ||
| context: `Import batch id: ${batchId}`, | ||
| submit: true, | ||
| newTab: true, | ||
| background: true, | ||
| openSidebar: false, | ||
| }); | ||
| ``` | ||
|
|
||
| `background` is intended to be paired with `newTab` so the hidden work does not | ||
| overwrite the user's active conversation. Use the returned `tabId` if the UI | ||
| needs to correlate follow-up status or deep-link into the run later. | ||
|
|
||
| A few more `AgentChatMessage` options exist beyond the ones covered in [Agent Chat](/docs/client-agent-chat#agentchatmessage), mostly for embedded or downstream-integration cases: | ||
|
|
||
| | Option | Type | Description | | ||
| | --------------------- | ----------- | -------------------------------------------------------------------------- | | ||
| | `background` | `boolean?` | With `newTab`, run without focusing the tab and show the run in `RunsTray` | | ||
| | `projectSlug` | `string?` | Optional project slug for structured context | | ||
| | `preset` | `string?` | Optional preset name for downstream consumers | | ||
| | `referenceImagePaths` | `string[]?` | Optional reference image paths | | ||
|
|
||
| ## MCP App Host Bridge {#mcp-app-host-bridge} | ||
|
|
||
| Routes embedded as MCP Apps should be URL-first: load the current artifact from | ||
| path/query params, render the real React route or a focused shared component, | ||
| and use the host bridge only for host-owned behavior. `@agent-native/core/client` | ||
| exports the helpers embedded routes call: | ||
|
|
||
| ```ts | ||
| import { | ||
| getMcpAppHostContext, | ||
| openMcpAppHostLink, | ||
| requestMcpAppDisplayMode, | ||
| updateMcpAppModelContext, | ||
| useMcpAppHostContext, | ||
| } from "@agent-native/core/client/agent-chat"; | ||
| ``` | ||
|
|
||
| `getMcpAppHostContext()` reads the latest pushed host context snapshot. | ||
| `useMcpAppHostContext()` subscribes React components to changes. The request | ||
| helpers (`openMcpAppHostLink`, `requestMcpAppDisplayMode`, | ||
| `updateMcpAppModelContext`) return `false` outside an embedded MCP App frame, or | ||
| `Promise<boolean>` inside a frame. | ||
|
|
||
| `sendToAgentChat()` (see [Agent Chat](/docs/client-agent-chat#sendtoagentchat)) uses the same bridge for auto-submitted prompts from embedded routes. Inside an MCP App embed created with `embedApp()`, auto-submitted messages (`submit` omitted or `true`) are forwarded to the bridge, which asks the containing host to add hidden context and send the visible user turn. `context` stays model-visible without being posted as user-facing chat. `submit: false` keeps the local prefill/review behavior because MCP Apps do not define a standard draft-prefill API. Internally this is the submitted-chat path sometimes surfaced as `agentNative.submitChat`. App code should still call `sendToAgentChat()` rather than posting that event directly. | ||
|
|
||
| The bridge itself, the `ui/*` JSON-RPC messages, the `agentNative.mcpHost.*` | ||
| wrapper relay, transplant vs. controlled-frame rendering, host context, and | ||
| display-mode requests, is owned by | ||
| [External Agents](/docs/external-agents#mcp-app-bridge). See [MCP Apps](/docs/mcp-apps) for the full protocol. | ||
|
|
||
| ## Custom Suggestion Chips {#custom-suggestions} | ||
|
|
||
| `<AgentSidebar>`'s `dynamicSuggestions` (see [Agent Chat](/docs/client-agent-chat#dynamic-suggestions)) already merges static `suggestions` with the framework's own context-aware heuristic. Pass `getSuggestions` instead when an app wants deterministic, domain-specific chips computed from the same `application_state` context rather than the framework's default heuristic. | ||
|
|
||
| ```tsx | ||
| <AgentSidebar | ||
| suggestions={["Summarize my inbox"]} | ||
| getSuggestions={(state) => | ||
| state.navigation?.view === "inbox" | ||
| ? [`Summarize ${state.navigation.label ?? "this"} label`] | ||
| : [] | ||
| } | ||
| > | ||
| <App /> | ||
| </AgentSidebar> | ||
| ``` | ||
|
|
||
| `getSuggestions` receives the same `navigation`/`selection`/`pending-selection-context` snapshot the built-in heuristic reads, so a custom implementation can react to exactly the state the default one does, just with app-specific rules instead of generic ones. | ||
|
|
||
| ## What's next | ||
|
|
||
| - [**Overview**](/docs/client-overview) — the `app/` and `public/` directory layout these primitives live in | ||
| - [**Data & Sync**](/docs/client-data) — the default action hooks and `useDbSync` most pages should use instead | ||
| - [**Sync Internals**](/docs/client-sync-internals) — `useChangeVersion`/`useChangeVersions` and the two latency paths a refetch can take | ||
| - [**Agent Chat**](/docs/client-agent-chat) — the default `sendToAgentChat`/`askUserQuestion` helpers most UI should use instead, and the staged agent chat context-state APIs | ||
| - [**Entry Points**](/docs/client-entry-points) — `app/entry.client.tsx`/`app/entry.server.tsx` and SSR streaming customization | ||
| - [**Context Awareness**](/docs/context-awareness) — the `navigation`/`selection` semantics built on `application_state`, and the `TAB_ID`/jitter-prevention walkthrough | ||
| - [**Generative UI**](/docs/generative-ui) — richer inline chat controls beyond `askUserQuestion` | ||
| - [**External Agents**](/docs/external-agents#mcp-app-bridge) — the full MCP App host bridge internals | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Pass custom suggestion builders through dynamicSuggestions
AgentSidebardoes not accept a top-levelgetSuggestionsprop; the callback belongs inside the object passed todynamicSuggestions. As written, this copy-paste example does not type-check and does not configure custom suggestions. Nest it asdynamicSuggestions={{ getSuggestions: (...) => ... }}and narrow the unknown navigation value before reading its fields.