Skip to content

Offline-first UX + client-side agent services (BYOK-or-managed)#154

Open
winlp4ever wants to merge 84 commits into
mainfrom
offline-first-phase-a
Open

Offline-first UX + client-side agent services (BYOK-or-managed)#154
winlp4ever wants to merge 84 commits into
mainfrom
offline-first-phase-a

Conversation

@winlp4ever

Copy link
Copy Markdown
Contributor

This branch carries two sequenced refactors on top of main.

Arc A — Offline-first UX unification

One-way local→synced boards with an explicit "Enable sync" promotion, and a single app shell for signed-in and signed-out users.

  • Unified dashboard + sidebar with LOCAL / SYNCED groups; local-first board creation everywhere.
  • Logged-out visitors land on the local dashboard (one shell, / as the front door) — no /local split.
  • POST /boards/{id}:adopt promotes a local board to synced; synced-board cap enforced at promotion (402, free tier limit).
  • Fix: logged-out redirect loop — subscriptions query now gated on sign-in.

Arc B — Agent services (client-side, BYOK-or-managed)

The agent runs client-side. Each external capability (LLM, web search, code, fetch) is a service that is independently BYOK (browser → provider with the user's key) or managed (browser → our proxy → provider with our keys, authed + metered + tier-gated). Multi-model via the backend catalog (Claude / OpenAI / Gemini / Chinese models); auto routing kept, deep research to be removed last.

  • G1 — pure per-kind service resolver (resolveService: BYOK / managed / off).
  • G2 — managed LLM proxy (/ai/llm + /ai/llm/stream) with auto + per-plan tiers; token-by-token streaming for managed and BYOK; shared fps flush-gate.
  • G3 — external tool services + citations: /ai/search, /ai/code (Daytona), /ai/fetch; URL-citation correction ported client-side over web-search sources. Image gen dropped; navigatefetch.
  • G4 — per-run metering: one whole agent run = one unit. Client mints one X-Run-Id per user message and threads it through every managed call; the server dedups via Redis SET-NX and charges the plan's AI quota once (meter_run). Over-quota (429) surfaces a friendly upgrade nudge.

Still to come on this branch

  • G5 — cutover all chat to the client engine, delete the legacy backend agent.
  • G0 — remove deep research (last; least independently testable).

Tests

Backend router + metering tests green; webui check-all (type-check + eslint) clean; 162 agent tests pass.

…rch, frontend agent

Local-only boards (no account, no backend): IndexedDB snapshot+oplog
persistence, Orama search, board lifecycle, and a frontend agent engine
(local tools + BYOK OpenAI-compatible client). harness-canvas gains a
`local` prop for a full-parity offline canvas; backend path unchanged.
PWA offline shell + persistent storage. 357 unit tests + Playwright specs.
Mirror the backend's message storage in the local-first stack: chat
metadata and messages now persist to IndexedDB (chats + chat_messages
stores, DB v2 idempotent upgrade) so the local agent transcript survives
reloads. Local message store loads on board open and saves per turn.

Also grounds the offline-first docs on the one-path model: the agent
runtime is always frontend, transport-switchable; no mid-refactor deploy.
Collapse the local agent onto one path: a `local` flag on ChatProvider,
read by local-aware data hooks (useChatMessages/useChatStreaming/
useChatSubmit/useActiveChatId), so the real chat components — floating
island, answer card, progress line, Conversation, and the full <Chat>
sheet (history + docked input) — render the in-browser engine unchanged.
Backend path is byte-identical when local=false.

Add multi-chat history for local boards: decouple chatUid from boardId
(many chats per board), IndexedDB v3 (by-board index + label),
listLocalChats, and a multi-chat local store (openBoard/selectChat/
newChat). The first turn mints and labels a chat from its prompt.

Replace the backend tools menu for local with a BYOK settings gear
(provider/model/key); gate deep research off. Default model gpt-5.4;
name DEFAULT_MAX_TURNS=30. Delete the LocalConversation/LocalAgentPanel
stopgaps. Add the full-roadmap doc.
The local engine ran each turn with only the new prompt, so the agent
forgot the conversation. Mirror the backend AssistantSession: prepend the
last 16 turns (role + markdown) before the new user message. Thread an
optional `history` through runAgent and build it from the stored
transcript in the local submit hook.
Answer text alone strips what an agent runtime needs to continue — the
tool calls it made and their results (e.g. the note ids it created).
Mirror the backend's to_chat_message/_compact_reasoning faithfully: each
assistant turn embeds its reasoning + tool traces inline as
<Reasoning><ToolCall name=…><Input/><Output/></ToolCall></Reasoning>,
capped per field. Tool-only turns (no answer text) are retained.
…ote tools

Bring the backend's agent design to the frontend so the local agent is the
real assistant, not a plain-note maker. It previously ran with no system
prompt at all.

- Prompts: port plan.system (local-adapted — backend-only tools trimmed) and
  the 3 skill guides as ?raw modules, loaded via a tiny {{var}} renderPrompt.
- Skills: learn_generate_diagram/mini_app/html_widget tools return their
  guidance on demand (progressive disclosure), mirroring backend learn.py.
- Tools: write_note (typed: rectangle/sheet/mini-app/widget, create or
  rewrite), get_note, edit_note (targeted unique-snippet replace); adapter
  renders them through the existing chat cards.

380 tests; type-check clean. Prompts are now canonical frontend-side.
No server logs made the local agent hard to debug and let failures
masquerade as answers. Add a debug logger that records every LLM
request/response, tool call/result, and error to the console (on by
default in dev) and an in-memory ring buffer inspectable via
window.__agentLog(). Surface provider errors (bad key, unknown model,
rate limit) richly instead of swallowing them, and prefix a failed turn
with "⚠️ Agent error" so it doesn't read like a normal reply.
Element-by-element migration map (have/partial/missing + phase) from the
backend Python agent runtime to the local-first frontend agent: lifecycle,
tools, and the two geometry systems (per-note content sizing + post-turn
Sugiyama arrange). Root-causes the mindmap-not-appearing bug to missing
layout/sizing, and notes the frontend already has the primitives (Dagre
autoLayout, sucrase mini-app compiler) — the agent just doesn't call them.
A batch of tightly-coupled fixes completing the agent-runtime migration so
the local agent produces correct, persistent, theme-aware boards. Hard to
decouple — they share the create→arrange→persist→reload pipeline.

- Layout: arrangeCreatedNodes lays out a turn's notes after it finishes
  (bidirectional mindmap for trees, flat Dagre LR otherwise) so they no
  longer pile at the origin.
- Sizing: port estimate_node_size (content-fit, per-type default boxes).
- Color: random Tailwind-200 fill per note (matches backend service.py).
- Theme: stamp _storedColors on create AND re-project from it on local
  hydrate, so colors are right on create and survive a refresh.
- Edges: link_notes attaches at node centers so canvas-harness auto-clips
  to the borders (the backend's anchor math, for free).
- Persistence: stamp createdAt (kills "Pending…"); persist + load messages
  in insertion order (was answer-before-question on reload).
- Observability: agent debug log (LLM I/O, tool calls, errors, turn-done)
  and loud provider errors.
- Disable backend-only AI affordances on local (answer-card transforms +
  context-menu AI section) via useIsLocalBoard, until ported.
- End-to-end pipeline test (turn → tools → arrange → persist → reload)
  guarding the round-trip.

396 tests; type-check clean.
…, single frontend

Migrate the iframe mini-app off the backend so it runs fully local, and fix
the agent's custom-node creation:

- State → IndexedDB (mini_app_state store, DB v4); drop /mini-app-state.
- Validate-on-write: write_note(mini-app) sucrase-validates before persist,
  returning line/col so the agent self-corrects in-turn.
- Single frontend: vite-plugin-singlefile builds the runtime into one
  self-contained public/mini-app/index.html loaded into an opaque-origin
  (sandbox=allow-scripts) iframe — no second origin/build/server. Cross-origin
  + HMR stays opt-in via VITE_MINI_APP_ORIGIN. Runtime trusts the parent by
  source when no host origin is configured.
- autoFit: custom-type nodes (sheet/mini-app/…) are born with autoFit:false at
  creation (mirrors note_to_wire_node), so the lib never grows them to content
  height. useStampNewNodes normalizes any other local emitter as a catch-all.

407 tests; type-check clean. Single-file build verified; iframe render needs a
live pass. See agent-runtime-migration.md.
…auto-label chats

Card-grid dashboard with open/rename (double-click)/delete-on-hover; renameBoard
in the local board registry and useLocalBoards; describe_board auto-labels a
board from its first chat via the BYOK client after a local prompt completes.
Scope typescript-eslint recommendedTypeChecked to the agent/board-local dirs with
pragmatic rule tuning (no-unsafe-* and require-await off, async JSX handlers
allowed). Fixes the fallout: void 10 genuine floating promises (navigate,
clipboard), a no-base-to-string catch fallback, redundant type assertions, and
orphaned imports.
The single seam a desktop SQLite build reimplements: a generic, transaction-
capable, collection-addressed persistence port with an IndexedDB adapter wrapping
openDim0Db. Repositories (next) are written once against it. Additive — no existing
code wired to it yet. 18 tests cover get/put/list (range, index, order), delete
(key + range), and transaction commit/rollback/read-your-writes.
BoardRegistry and BoardPersistence now access storage through the engine port
instead of opening idb directly, so a desktop SQLite adapter swaps in unchanged.
Each self-owns an engine when none is injected (lifecycle unchanged for current
callers) and leaves an injected shared engine open for its owner. deleteBoard now
cascades to the board's chats + messages too, fixing orphaned chat records.
…al shapes

Chat/message and mini-app-state persistence move behind repos over the engine
port. Canonical LocalChat/LocalMessage types (backend Chat/Message field names,
uid→id, graph_uid→boardId) replace the ad-hoc ChatRecord, ending the local/backend
shape drift. Adds deleteChat (cascades messages) and deleteMessage — previously
impossible locally, so chat history could only grow. The existing functional
helpers now delegate to the repos; the composition root (next) centralises the
engine lifecycle. 26 new repo tests.
createLocalStores(engine) composes BoardRegistry + ChatRepo + MiniAppRepo over a
single StorageEngine; getLocalStores() is the app-wide singleton that opens the
IndexedDB engine once. Every consumer (board hydrate, board list, chat persist,
mini-app state, board auto-label) now shares that one connection instead of
opening its own per call. A desktop build swaps the engine for SQLite at this one
seam. Tests reset the singleton alongside the fake IndexedDB.
filterContentByLayer keeps only a rootId layer's nodes/edges (root = parentId
null), mirroring the backend get_graph(root_id). Applied at the store-projection
boundary (applyContentToStore) so BoardPersistence stays whole-board — the
oplog/snapshot still cover every layer, so filtering can never drop one. No-op
for today's flat local boards (all parentId null); the foundation for folder
navigation.
removeNodeSubtree BFS-collects a node and all its parentId descendants and removes
them in one batch — the local analog of the backend GraphStore.delete_node. Since
parentId is a Dim0 concept the canvas store doesn't know, deleting a folder used to
orphan its children; now the whole subtree goes (and one undo restores it, edges
included). Wired into every node delete action. Leaf nodes are unaffected.
applyContentToStore now clears the store (edge+node removes, in the one remote
batch) and clearHistory before applying, mirroring the backend hydrateBoardStore
replace mode. This is what makes a layer SWITCH safe: entering/leaving a folder
re-projects a different parentId layer into the same long-lived store, which
would otherwise accumulate every layer's nodes. The (re)load is now non-undoable.
…out)

Double-clicking a folder on a local board now enters it — navigating to
/local/$boardId?root_id=<folder> (same root_id mechanism as backend boards, was
gated behind !local). local-board-screen reads root_id and scopes the board to
that layer; the harness re-projects it. A local breadcrumb (built by walking the
whole-board parentId chain — buildLayerPath, no backend note-path endpoint) shows
Board > A > B and jumps to any ancestor. Nodes created inside a folder inherit its
parentId via the stamp hook, so create + nest + navigate now work end-to-end.
…creation

create_note/write_note/link_notes now set data.parentId = ctx.rootId when the
node/edge is born, instead of relying on the post-hoc stamp hook. The hook applied
parentId as a separate update op that didn't survive oplog replay attached to the
add, so agent notes made inside a sub-board persisted with parentId=null and
resurfaced on the parent board after reload. This mirrors the backend passing
root_id to build_note. The agent context now carries rootId (read from the board
app store at submit).
useLocalSearchIndex owns the LocalSearchIndex on a local board — created empty at
mount, attached to the store so the hydrate batch + every edit index incrementally
(no rebuild race), published on a module ref (search-index-ref). The agent turn
reads that ref into ctx.search and search_notes is registered, so the agent can
find notes by title/body. Wiring core extracted (wireSearchIndex) and tested
without React; ref lifecycle + search_notes covered.
The engine tests become a reusable contract (engine-contract.ts) run against both
IndexedDbEngine and a new InMemoryEngine — both pass identically (compound-key
ranges, open bounds, secondary index, tx rollback), proving the port is
implementation-agnostic and giving the SQLite adapter a template. Repo suites
(ChatRepo, MiniAppRepo, BoardRegistry) now run against both engines. A declarative
COLLECTIONS descriptor (schema.ts) is the single source of truth for the store
schema, consumed by both idb.ts and the in-memory engine.
Each tool's JSON-schema properties now carry a description (the arg's role), not
just a type, so the local LLM gets the same guidance the backend gives — e.g.
link_notes' source/target/label went from bare types to described (label examples:
'yes','no','then','reads','causes'). Vocabulary aligned with the backend note tools.
search_notes (memory_search), web_search and run_code had docstrings without an
Args section, so the Agents SDK generated their schemas with typed-but-undescribed
arguments. Added Google-style Args: (underscoring the unused injected wrapper,
documenting the used one) so each argument's role reaches the model.
Replace hand-written JSON-Schema tool params + Record<string,unknown> handlers
with Zod schemas via a defineTool helper — the schema now types the handler,
validates the model's call at runtime (structured error, no throw/silent coercion),
and is converted to JSON Schema for the LLM (z.toJSONSchema, $schema stripped).
This is how Vercel AI SDK / OpenAI Agents SDK define tools. Removes all str()/num()
coercion; edit_note.field becomes a real enum. Adds zod@4 as a dependency. Tests
cover validation (bad args -> error, handler not run) and that descriptions/required
reach the wire.
winlp4ever added 27 commits July 6, 2026 23:47
useBoardSyncV2 now sets the local presence name (email local-part) + a
deterministic per-user color on mount, so peers see named, colored cursors.
Cursor/selection tracking + send/apply were already wired via useLocalPresence
and attachSync; this was the missing identity seed.
…rigin)

Since cd59bef the mini-app runtime defaults to single-frontend: served from
/mini-app same-origin in an opaque (allow-scripts, no allow-same-origin) iframe.
But the deploy config still forced the legacy cross-origin path via a
VITE_MINI_APP_ORIGIN default of localhost:5001, which now points at a port
serving the host bundle — the iframe loaded the host app, the handshake timed
out, and the origin-match guard fired. Default VITE_MINI_APP_ORIGIN empty in the
entrypoint + both compose files (build + images/prod) so single-frontend is the
default everywhere; set it explicitly only for opt-in cross-origin (dev HMR /
real subdomain).
canvas-harness redo() re-applies the original batch with its original id
(undo mints a fresh one). A reused id collides with batch-id dedup: the local
oplog seen-set skips it (redo not persisted) and the relay treats it as an
already-applied replay (redo not broadcast). Each undo/redo is a new forward op
in the shared log, so rewrite history-batch ids to fresh ones at the source
(first change subscriber, before persistence + collab). Fixes redo for v2,
legacy-on-new-backend, and local-board persistence.
The node style handler shipped a non-color change (border, font) with the
sender's theme-adapted display colors but WITHOUT data._storedColors, so a peer
in a different theme applied those colors verbatim (dark colors on a light
board). Attach _storedColors even for non-color changes so the peer re-projects
for its own theme — mirrors the edge handler. Affects both clients.
…rsist

The backend persists an edge's curve from a world-space _midpoint, not from
canvas-harness's native cubic control points. v2 sent raw ops without _midpoint,
so a manually-dragged curve wasn't persisted and reverted to the default bezier
on reload. Add an enrichOutbound coordinator hook + enrichEdgeMidpoints that
attaches _midpoint to a clone before send (raw oplog keeps control for local
reload). Mirrors the legacy client.
Self-echo guard: applyRemote drops a batch whose clientId is ours, so an echoed
op can't be double-applied through the rebase.

Send coalescing: local edits within a 75ms window merge into ONE relay message
(ops concatenated, then deduped by dedupeRepeatUpdates so repeat same-target
updates collapse to one) — so rotate's per-tick op flood ships one frame per
window instead of one per pointermove, matching the legacy client. One ack
covers the whole merged range: inFlight tracks the seq/batchId list and
markSyncedTo advances the cursor to the max, so a reconnect never re-sends the
covered records. coalesceMs defaults to 0 (immediate, unchanged) — only the v2
mount opts into 75ms, so the existing harness behaves identically.
The module docstring claimed full style/data translation was deferred, but
_node_patch_to_note_data/_wire_node_to_note translate style, data.properties,
z-order, scope and label. Only group.* (unimplemented) and frame.reorder (bulk
z-shuffle; per-node z works) are genuinely deferred.
Graduate the dim0SyncV2 dev flag to a persisted BoardMeta.syncEngine
field. use-sync-engine resolves a synced board's collab client from the
local registry (dev flag stays as a force-v2 override); harness-canvas
defers hydration until the engine is known so it never mounts the wrong
client first. Adds BoardRegistry.setSyncEngine for the local->synced
promotion (E2).
BoardsHome renders two groups — "On this device" (local-only replicas,
no account) and "Synced" (backend boards). Both /boards (guarded) and
/local (unguarded) point at it: signed-out users see only the on-device
group, signing in reveals synced. partitionBoards dedupes by id so a
promoted board (present in both the local registry and the backend list)
shows once, under Synced. Folds the standalone LocalDashboard screen in;
its card leaves stay in local-dashboard.tsx.
POST /boards/{id}:adopt creates a synced graph under the caller with the
board's client-provided UID, then rebuilds its content via the same
apply_batch path the collab relay uses. The stored graph is the snapshot
base a joining v2 client hydrates from, so no oplog seeding is needed
(seq starts at 0). Idempotent for the owner (retry-safe); a UID owned by
someone else is rejected 409 rather than overwritten.
Adds an "Enable sync" action on on-device dashboard cards. The
orchestrator loads the board's content, POSTs it to the adopt endpoint,
compacts the local store so the outbox has nothing pending (else the v2
coordinator would re-upload the history the server just ingested), then
flips BoardMeta to synced/v2 last so a mid-flow failure stays local and
re-runnable. Signed-out users are routed to sign-in. On success the
synced-board list is invalidated so the card moves to the Synced group.
BoardKindBadge ("On device" monitor glyph vs "Synced" cloud glyph) on
every dashboard card and, for local boards, in the canvas top-right
chrome (which otherwise has no collab indicator). Makes a board's
local/remote status legible per-board, not just from the group header.
New boards are created LOCAL everywhere (sidebar + dashboard); the direct
'new synced board' entry points are gone — synced boards come only from
promotion. The sidebar gains a LOCAL group (local registry, with a New
board action + right-click Enable sync) and renames owned boards to a
SYNCED group. /home now embeds the same unified BoardsHome (local +
synced groups) instead of the old synced-only dashboard, so /, /home,
/boards and /local share one view.
Window safety: replace load+compact with capture+foldBase. capture()
snapshots the exact base shipped to adopt; foldBase() folds only up to
that seq, so an edit made DURING the adopt round-trip stays pending in
the oplog and the v2 coordinator ships it on connect — no loss, no
double-apply. Adds real-persistence tests for the window (preserve +
pending, no-window, and a regression guard proving naive compact drops
the edit).

Synced-board cap: local boards stay unlimited; the free cap now bites at
promotion (one-way flip can't be bypassed by demoting). Enforced in the
UI (useEnableSync blocks + upgrade toast) and on :adopt (402 via
is_billing_active + owned-graph count; paid unlimited, OSS uncapped).
Render the app shell for everyone (auth pages excepted) instead of only
when signed in, and route local boards through it (drops the /local bare
branch) so the sidebar/trigger work on a local board. / is the universal
front door: signed-out lands on the local dashboard, signed-in gets the
chat home; a missing or stale/expired token falls through to local
instead of bouncing to /signin. Sidebar is auth-aware (LOCAL group + New
board + sign-in CTA when logged out; Dashboard/Chats hidden).

Fixes the logged-out redirect loop: the signed-out sentinel userId is
the TRUTHY string "root", so enabled: !!userId fired authed board/chat
queries logged-out -> 401 -> forced /signin. Add isSignedIn() (lib/auth)
and gate every auth-only query on it; also gate list-available-services
and the sheet-panel note-path so a local board makes zero backend calls.
Connectivity ping/overlay now gate on signed-in + non-local.
…loop)

useListSubscriptions had no enabled gate and is mounted in SidebarLabel,
which now renders in the always-on shell. Logged-out it fired
GET /subscriptions -> 401 -> api.ts window.location.replace('/signin'),
so / bounced to sign-in even with no token (reproduced in incognito).
Gate it on useIsSignedIn like the other shell queries.
…(G1)

Introduce the service layer that generalizes the single LlmClient seam to
every off-device capability (llm/search/code/image/navigate). resolveService
is a pure decision: BYOK wins by default, managed (our keys) when signed-in +
entitled, off otherwise; preferManaged + per-kind managedAllowed gating are
supported. Adds per-kind client interfaces + llmClientFromResolution, and routes
the local agent's LLM acquisition through resolveLocalLlm. Behavior-neutral:
managed is forced off until G2, so the local BYOK path is unchanged. Rigorous
unit tests cover the full resolution matrix + the client factory.
The integration helper the local hooks call was untested. Assert BYOK
config → ByokLlmClient, no config → null, and that managed stays off in
G1 (BYOK-or-null, never a non-BYOK client) — the guarantee the G1 rewire
rests on.
Add POST /ai/llm: forwards ONE model turn with OUR keys through the shared
catalog (multi-provider + auto-model routing + per-plan tier gating); explicit
out-of-tier model is 403. Client-side, ManagedLlmClient reuses ByokLlmClient
with a completer that hits /ai/llm (OpenAI-shaped wire → same mapping, different
transport). resolveAgentLlm now returns the managed client for a signed-in user
with no BYOK key, so the local board's assistant works with no key (LocalBoardScreen
gates on byok-configured OR signed-in). BYOK still wins by default.

Non-streaming, non-metered: streaming (G2b) and per-run metering (G4) are the
explicit follow-ups. Rigorous tests: backend proxy (auto/simple/clamp/403/text/
tool-calls/forwarding) + client mapping + resolver wiring.
The client engine was turn-atomic; now it streams. LlmClient gains an optional
completeStream (text deltas → final turn); the agent loop prefers it, emitting
cumulative assistant_text per delta (UI renders the latest, unchanged).

Backend: POST /ai/llm/stream returns NDJSON delta lines + a final assembled
message (litellm stream=True; tool-call fragments stitched by index). Frontend:
ManagedLlmClient consumes it via the existing handleStreamingResponse reader;
ByokLlmClient streams via the OpenAI SDK; shared assembleStreamedTurn stitches
OpenAI chunk fragments. Non-streaming complete() stays as fallback + for tests.

Rigorous tests: chunk assembler (text/tool-frag/parallel/empty), managed + BYOK
completeStream, loop streaming (cumulative deltas + tool round), backend stream
(deltas→final, tool-call assembly).
The token-batching throttle (maxFps + safety-interval) lived inline in build.ts
(the legacy backend-agent stream builder) and was missing from the new client
engine, which repainted per token. Extract it to utils/stream/throttle.ts
(createFlushGate — pure, now unit-tested) and reuse it in BOTH: build.ts calls
it (behavior-preserving — same boolean), and the engine render loop coalesces
token deltas to ~10fps (structural events force an immediate repaint; the final
frame always flushes). One battle-tested throttle, no duplication.
Add POST /ai/search — dispatches to the existing per-engine search functions
(perplexity/tavily/linkup/exa) with OUR keys, returns {answer, results}. Client:
managedSearchClient + a web_search tool (defineTool), resolved via the service
layer and added to the agent toolset only when available (signed in → managed).

Web search is managed-only in practice: providers block browser CORS and we
don't relay a user's key, so the BYOK seam stays open in the resolver but is off
for search. Rigorous tests: backend dispatch/mapping/unknown-engine, managed
client, the tool, and resolution (signed-in → client, signed-out → null).

Next G3 slices: citations (port post_process_url_citations over these sources),
then code / image / navigate services.
The sources panel already renders by parsing URLs from the answer text
(extractAnswerWebSources), so citations largely worked; the missing quality
piece was correcting mangled/truncated links. Port post_process_url_citations
to TS (utils/citations.ts — regex + char trie, MIN_MATCHES=10, faithful to the
backend incl. its trailing-'.' quirk). collectWebSearchSources gathers the real
web_search URLs across the run; the submit hook snaps the final answer's links
to them before the last render + persist. No new UI. Thorough tests: exact/
truncated-completion/garbage-trim/unrelated/short-prefix/no-sources/multi-source
+ the source collector.
POST /ai/code runs source in our Daytona sandbox via the existing execute_code
(which self-handles bad language + unconfigured Daytona), returns status/stdout/
stderr/duration_ms. Client: managedCodeClient (maps status→ok) + a
code_interpreter tool (defineTool, python|javascript, defaults python), resolved
via the service layer and added to the toolset when available (signed in).
Managed-only (a browser can't run a sandbox; no key relay).

Tests: backend map/default-language/error passthrough; client success+error
mapping; tool run/default/unsupported-language rejection; resolution.
Add POST /ai/fetch — reads one URL via the existing fetch_content (Tavily
extract) with our keys, returns {url, title, text}. Client: managedFetchClient
+ a 'fetch' tool (defineTool), resolved via the service layer and added to the
toolset when available (signed in). Managed-only.

Per product call: image generation is dropped from the service abstraction, and
'navigate' is renamed to 'fetch'. ServiceKind = llm|search|code|fetch;
ImageClient/ImageResult removed. Tests: backend map + answer-fallback; client
fetch + null-title mapping; the 'fetch' tool; resolution.
meter_run guards all /ai/* endpoints: the FIRST managed call of a run (deduped
by the X-Run-Id header via a Redis SET-NX, RedisStore.set_if_absent) charges the
plan's AI quota through the existing enforce_rate_limit (429 when over); later
calls in the same run are free — decision-#1 'one run = one unit'. A call with
no run id is charged on its own (no free-riding). Reuses the shared rate limiter,
so managed AI shares the same daily/monthly quota as the legacy agent.

Tests: meter_run (first-charged / repeats-free / distinct-runs / no-id-each /
over-quota-429); existing /ai tests override meter_run to a no-op.

G4b (client: mint + send X-Run-Id per message + over-quota UX) is next.
Client mints one run id per user message and threads it through every
managed call (LLM + search + code + fetch) so the server meters a whole
agent run as a single unit (deduped by X-Run-Id; backend meter_run). The
four managed client factories take an options object with runId; their
default transports attach the header. An over-quota 429 surfaces a
friendly upgrade nudge instead of a raw error.

G5 (cutover all chat to the client engine, delete legacy backend agent)
is next; G0 (delete deep research) stays last.
The offline-first UX unification changed the surface the local e2e drove:
the dashboard groups boards under an "On this device" list (the create
tile is a "New Board" card, not a button), and the local-board chat is
the floating island ("Ask about this board…", submit on Enter) with an
inline BYOK key panel. The BYOK client also streams turns via SSE now, so
the mocked provider must answer stream requests with SSE chunks — a plain
JSON body was being dropped, so the tool call never executed.

Rework the three specs against the new selectors and stream the mocked
write_note tool call; the agent test now asserts the streamed answer
renders and the executed tool's result was fed back to the model. Also
give the icon-only SendButton an aria-label (a11y + testability).
Replace the bare BYOK panel with a single Services popover opened from the
floating island (gear at the composer far-right), styled like the steps
popover (sidebar shell, rounded-2xl, layered shadow, mono header). One row
per capability (Models / Web search / Code / Fetch) shows the resolved
key-source as a status pill.

Policy — "our keys first": signed in prefers managed for every service,
even with a saved key (a key is only used signed-out). Encoded once in a
shared agentResolveContext so the submit loop and the panel resolve
identically. Only Models is BYOK-able from the browser; the managed-only
services read as "Needs an account" with a Sign in CTA when signed out.

Also: board toolbar shows border-only by default, shadow on hover; give
the SendButton flow an accessible services trigger.

webui(board): toolbar shadow only on hover
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant