Desktop: a workflow never invents a primary knowledge base (capture, picker) - #256
Merged
Conversation
…primary The create-workflow modal computes the captured default in two places: from the chat's own selection read, and from the generated workflow's `knowledge_bases` block. Both fell back to `visible[0]` when the chat named no primary, so the saved workflow's `default` was the first visible base. `apply_knowledge_selection` maps `default: Some(id)` to `PrimaryUpdate::Set(id)`, so every chat the workflow started got a primary, the target of KB-less writes, that the chat it was captured from never had. The daemon's rule is the opposite. `plan_knowledge_selection` never infers the primary from `visible`, and the doc on `plan_workflow_knowledge_selection` says why: a promoted primary turns "I did not say where to write" into a commit into someone's base. With no primary, a KB-less write fails and names the candidates. Both paths now go through `primaryAmong`: the named primary if it is among the bases the workflow will see, and otherwise `null`. The daemon's block for a chat with no primary omits `default` entirely (`skip_serializing_if`), and that reads as `null` too. A primary outside `visible` becomes `null`, not unioned in the way the daemon unions an author's `default`. The daemon does that because somebody wrote the workflow and meant it. A captured primary outside its own set is an inconsistent read. The block is one locked snapshot and never is one, but the modal's own read is two requests (the base list and the selection), and a base created or deleted between their answers leaves the selection naming a base the list lacks. Unioning could save a deleted base as the default, which `set_visible_kbs` refuses, so every chat the workflow starts would fail with a 400. After a failed list it would save the primary as the only visible base, hiding every other one. Tests: the read path with no generated block; the generated block with `default: null` and with `default` absent (the daemon's own wire shape); and a primary outside the visible bases, once from the read and once from the block. All five fail before this change: each saved the first visible base.
…wledge base The previous commit stops the create-workflow modal from inventing a primary when it captures a chat's selection. Editing the selection afterwards still invented one, in two places: - The picker (`WorkflowResourcePicker`). Switching a base on made it the default whenever none was set, and switching the default off handed the role to `next[0]`. - The modal's `onKnowledgeBaseIdsChange`. It promoted `ids[0]` on any change while no default was set, including switching off an unrelated base. So a user who captured a chat with no primary and then removed one base got the first remaining base as the write target of every chat the workflow starts. These are user gestures, not captures, which is the case for keeping them. They go anyway. The gesture is "this workflow may search this base", and the promotion adds "and KB-less writes go here", which the user did not ask for. It is the promotion the daemon removed in 3a6e688 ("never promote a sole visible base to the primary"): "exactly one candidate" was treated as consent, which it is not, and the author who wants that base as the write target has a field to say so. In the picker, that field is the Default control, one click away on every selected row. - Switching a base on changes the selection only. - Switching the default off leaves no default, rather than passing it on. - The modal's handler keeps only the membership rule: a named default stays while its base is selected and becomes `null` when it is not. - The Default control is now a toggle (`aria-pressed`), so pressing the current default clears it. Without that, "these bases, and no default", the state a chat with no primary now captures, could not be restored once any base had been made the default, short of switching it off and on again. Each control is named for its row ("Default KB: lab-notes") so the pressed state says which base it belongs to. Tests, in the modal: switching a base on, switching another off, and switching the primary off each save no primary. All three fail on the previous commit. Switching a base on still fails with only the handler fixed (the picker names the base) or only the picker fixed (the handler names `ids[0]`). In the picker: switching on leaves the default unset, switching the default off clears it, and the Default control names a base and clears it when pressed again. All three fail before this change. A fourth, switching another base off leaves the default alone, passes either way as a guard against clearing it on every toggle.
No conflicts. The point of the merge is PR #262, which fixes the harness defect this branch's `Unit tests (vitest)` run tripped over: FAIL src/components/MentionPopover.privateChat.test.tsx > offers this chat's knowledge bases, not every base, and names its primary TypeError: selectedElement.scrollIntoView is not a function at src/components/MentionPopover.tsx:819 (commitHookPassiveMountEffects) Not this branch's feature and not an assertion: the spec installed a jsdom `scrollIntoView` stub in `beforeEach` and DELETED it in `afterEach`, but the palette's scroll runs from a PASSIVE effect that React flushes when setup.ts's `cleanup()` unmounts — and vitest runs the spec's own `afterEach` BEFORE that. The property is already gone when the queued effect fires, so the throw lands inside React and fails a test that had already asserted its point. The window is one scheduler turn wide, which is why it opens on a loaded CI runner and reads as flakiness. Measured rather than assumed: * the polyfill now lives once, process-wide, in ui/desktop/src/test/setup.ts with a regression guard in src/test/scrollIntoViewPolyfill.test.tsx — both present on origin/main at 99bf415 (PR #262) and on NEITHER PR branch, which merged an older main; * this branch still carried the per-test install/delete at MentionPopover.privateChat.test.tsx:77-78 and :109-111, and the merge replaces the whole spec with main's; * vitest.config.ts sets no `pool`/`isolate`, so the defaults (forks, isolate: true) apply and cross-FILE pollution is structurally impossible — the leak was always inside this one file. After the merge, on this branch: npx vitest run --testTimeout=30000 --hookTimeout=30000 (excluding src/utils/artifactCdnAssets.browser.test.ts, whose afterAll browser.close() exceeds 30 s under load on pristine main too) Test Files 458 passed (458) Tests 5179 passed | 1 skipped (5180)
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.
What was wrong
The "Create workflow from this chat" modal (
CreateWorkflowFromSessionModal.tsx) invented a primary knowledge base when the chat had none. It computed the captureddefaultin two places, and both fell back to the first visible base:GET /knowledge/active):primary && visible.includes(primary) ? primary : (visible[0] ?? null).knowledge_basesblock: the same expression. The result was used both for the save path's fallback (generatedResourcesRef) and for the picker's state.apply_knowledge_selection(crates/biorouter/src/workflow/runtime.rs) mapsdefault: Some(id)toPrimaryUpdate::Set(id). So every chat the workflow started got a primary, the target of KB-less writes, that the chat it was captured from never had.The daemon's rule is the opposite.
plan_knowledge_selectionnever infers the primary fromvisible, and the doc onplan_workflow_knowledge_selection(routes/agent.rs) says why: a promoted primary "silently turns 'I did not say where to write' into a commit into someone's base". The Knowledge section of CLAUDE.md also says an explicit no-primary must not fall back.Editing the selection in the modal invented a primary as well:
WorkflowResourcePicker.tsx) made a base the default when it was switched on and no default was set. Switching the default off handed the role tonext[0].onKnowledgeBaseIdsChangepromotedids[0]on any change while no default was set, including switching off an unrelated base.Decisions
A captured selection with no primary saves
default: nullBoth capture paths now go through one helper,
primaryAmong(primary, visible). It returns the named primary if that base is among the ones the workflow will see, and otherwisenull. The daemon's block for a chat with no primary has nodefaultkey at all, because the field isskip_serializing_if = "Option::is_none". That shape reads asnulltoo, and it's what the daemon actually sends, so it's tested alongside the literalnull.A primary outside
visiblebecomesnull, not unioned inThe guard that a named default must be in
visiblestays. What changes is the fallback, fromvisible[0]tonull, rather than a union intovisiblethe wayplan_knowledge_selectiondoes it:defaultthat isn't invisiblethere is an author who "plainly meant it". Nobody wrote a captured selection. A captured primary that isn't in its own set is an inconsistent read, not an intent.selection_unlockedfilters the pointer againstkb_ids. The guard on that path is defensive only.listBasesandGET /knowledge/active, sent together and answered in either order. A base created or deleted between the two answers leaves the selection naming a base the list doesn't contain. The modal can't tell which of the two happened.default.set_visible_kbsrefusesSet(id)outside the resulting set, so every chat the workflow starts fails with a 400. After a failedlistBases, wherevisibleis empty, union would savevisible: [primary]and hide every other base in each new chat.nullis the safe side. With no primary, a KB-less write fails and names the candidates. The user can still press Default in the picker. If the generated block does name the chat's primary, the save path already falls back to it (defaultKnowledgeBaseId ?? generated.default).Switching bases on or off no longer names a primary
These are user gestures, not captures, and that is the case for keeping them. I removed them anyway. Switching a base on says "this workflow may search this base". The promotion added "and KB-less writes go here", which the gesture never asked for. It is the same promotion the daemon removed in 3a6e688 ("never promote a sole visible base to the primary"): "'exactly one candidate' was treated as consent, which it is not — the author who wants that base as the write target has a field to say so." In the picker, that field is the Default control, which is one click away on every selected row.
They also undid the capture fix. With a captured chat with no primary, switching one base off made the first remaining base the write target.
The modal's handler keeps only the membership rule: a named default stays while its base is selected, and becomes
nullwhen it isn't.The Default control is now a toggle (
aria-pressed). Without that, a user who pressed Default on any base couldn't get back to "these bases, and no default", short of switching the base off and on again. That is the state a chat with no primary now captures. Each control's accessible name includes its row ("Default KB: lab-notes"), so the pressed state says which base it refers to. The visible label is still "Default".Changes
Commit 1, the capture (
fix(desktop): a workflow captured from a chat with no primary has no primary):CreateWorkflowFromSessionModal.tsxaddsprimaryAmongand uses it on both capture paths. It also removes the duplicated ternary on the generation path.Commit 2, the gestures (
fix(desktop): only the Default control names a workflow's primary knowledge base):WorkflowResourcePicker.tsx:toggleSelectedchanges only the selection, except that switching the default off clears it. The Default control is a toggle witharia-pressedand a per-row name.CreateWorkflowFromSessionModal.tsx:onKnowledgeBaseIdsChangekeeps the membership rule and never promotes.The two commits are split so the second can be dropped on its own if the picker decision goes the other way. The first stands alone and passes its own tests.
Tests
CreateWorkflowFromSessionModal.test.tsx, new block "the workflow's primary knowledge base". Each test asserts the savedknowledge_bases:default: 'soul'default: nulldefault: null(the read fails, so the block is the only source)'lab-notes'nulldefaultkey (the daemon's wire shape)'lab-notes'null'soul'null(a union would give'new-notes')visible'lab-notes'null'grant-drafts', measured on commit 1 alonenull'soul', on commit 1null'soul', on commit 1nullWorkflowResourcePicker.test.tsx(new file):onDefaultIdChange('soul')onDefaultIdChange('soul')onDefaultIdChange(null)aria-pressed, thennullMutation checks, run in stages with the final tests:
'grant-drafts', because the picker names it.'soul', because the handler promotesids[0].The shared
settleReadsandsaveTheWorkflowhelpers move to module scope in the modal test so both blocks can use them. They are unchanged otherwise.Verification
components/workflows/directory passes 127 of 127 on the rebased tree.npm run lint:checkpasses on the rebased tree: typecheck, ESLint with--max-warnings 0, themes, 332 contrast assertions, and tokens.npx prettier --checkon the four changed files passes, run fromui/desktop.npm run test:runexited 1 on every run with no failing test of mine. The machine's load average was 400–600 on 16 cores throughout, from other sessions' cargo builds. Every run is below, including a control on pristinemain:artifactCdnAssets.browser.test.ts, whoseafterAll(browser.close()) timed out at 30 s. Both of its tests passed. 4993 passed, 1 skipped.chatStreamStore.adversarial.test.tsx› "keeps notifying React after the backlog commits" (1 failed). Both files pass when run alone.main(6455bc2)main(6455bc2)5019 = 5007 + the 12 new tests, and 446 files = 445 + the new picker test file.
Co-Authored-Bytrailer.Not in this PR
knowledge_baseskey. Measured with a throwaway test: switch every base off in the picker, and the saved workflow has no key, so every chat it starts sees every base. The same happens for a chat that hid every base: the daemon's block for it is{}, meaning "hide everything", and the save path'slength > 0 || defaultcheck drops it. The failed-read case, where no key is right, has to stay distinguishable. Flagged as a separate task.routes/agent.rs,apply_workflow_knowledge_selection(...)?returns early withoutdiscard_failed_new_session, unlike the provider-bind and prompt failures just above it. A workflow whosedefaultnames a since-deleted base makes every start fail and leaves a chat behind each time. Flagged as a separate task.kb_ids, the daemon's own coherent visible set. The modal instead computeslistBases − hiddenfrom two requests, which is the only reason its primary can fall outsidevisible.KnowledgeSelection(shared with the/palette) doesn't exposekb_idstoday.🤖 Generated with Claude Code