Offline-first UX + client-side agent services (BYOK-or-managed)#154
Open
winlp4ever wants to merge 84 commits into
Open
Offline-first UX + client-side agent services (BYOK-or-managed)#154winlp4ever wants to merge 84 commits into
winlp4ever wants to merge 84 commits into
Conversation
…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.
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
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
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.
/as the front door) — no/localsplit.POST /boards/{id}:adoptpromotes a local board to synced; synced-board cap enforced at promotion (402, free tier limit).subscriptionsquery 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);
autorouting kept, deep research to be removed last.resolveService: BYOK / managed / off)./ai/llm+/ai/llm/stream) withauto+ per-plan tiers; token-by-token streaming for managed and BYOK; shared fps flush-gate./ai/search,/ai/code(Daytona),/ai/fetch; URL-citation correction ported client-side over web-search sources. Image gen dropped;navigate→fetch.X-Run-Idper 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
Tests
Backend router + metering tests green; webui
check-all(type-check + eslint) clean; 162 agent tests pass.