Skip to content

refactor(web): convert current-platform-assistant hook to Zustand store (LUM-1718)#31433

Merged
ashleeradka merged 1 commit into
mainfrom
claude/refactor-platform-assistant-hook-DRjFS
May 21, 2026
Merged

refactor(web): convert current-platform-assistant hook to Zustand store (LUM-1718)#31433
ashleeradka merged 1 commit into
mainfrom
claude/refactor-platform-assistant-hook-DRjFS

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Summary

Replaces the hand-rolled useSyncExternalStore + module-level listener set in use-current-platform-assistant with a Zustand persist-backed store, matching the rest of the codebase.

  • New domains/settings/current-platform-assistant-store.ts — Zustand store with persist middleware, wrapped via createSelectors(). State is byOrg: Record<orgId, assistantId>; actions are setAssistantId(orgId, id) / getAssistantId(orgId).
  • A custom StateStorage adapter keeps writes split across the existing per-org vellum_current_assistant_id__{orgId} localStorage keys, so the on-disk format stays backward compatible with the prior implementation.
  • Cross-tab sync is preserved: a single storage event listener calls persist.rehydrate() on any matching key, replacing the previous module-level listeners set + notify() plumbing.
  • React Query still owns the platform assistants list. useCurrentPlatformAssistant is now a thin composition of the store + the list query — no useSyncExternalStore, no module-level pub/sub.

Linear: https://linear.app/vellum/issue/LUM-1718

Test plan

  • bunx tsc --noEmit passes
  • bun run lint passes (no new warnings)
  • Settings → Switch Assistant: selecting a different assistant updates the active row and survives reload
  • Selection scoped per org — switching organizations restores that org's previously selected assistant
  • Open in two tabs: switching in tab A reflects in tab B without a reload
  • Existing vellum_current_assistant_id__{orgId} localStorage entries still apply on first load after upgrade

Generated by Claude Code

…1718)

Replace the hand-rolled `useSyncExternalStore` + module-level
listener Set in `use-current-platform-assistant` with a Zustand
`persist`-backed store. A custom `StateStorage` adapter keeps the
existing per-org `vellum_current_assistant_id__{orgId}`
localStorage key format so the on-disk shape stays backward
compatible. React Query still owns the platform assistants list;
the hook is now a thin composition of the store + the query.
@linear
Copy link
Copy Markdown

linear Bot commented May 21, 2026

LUM-1718

@ashleeradka ashleeradka marked this pull request as ready for review May 21, 2026 02:31
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Checked against all five Migration Review Gates from the Zustand patterns KB.


Gate 1 — Auth/org-switch

storedId is derived inline as orgId ? (byOrg[orgId] ?? null) : null. When org changes, orgId changes → derivation re-evaluates against the right slice of byOrg. No stale-state-on-switch risk. The store is already org-scoped, so there's no cross-user bleed concern (unlike the prechat_native_screen key that PR #31431 just fixed — different problem, handled differently).

Gate 2 — Cleanup completeness

The storage event listener is module-level (registered once at module load), same as the old listeners set in the deleted code. Module-level listeners don't leak — they live for the lifetime of the module. No component-scoped subscription to clean up.

Gate 3 — CONVENTIONS.md — N/A for an intra-domain refactor. No new pattern being established beyond what's already documented.

Gate 4 — Selector usage

const byOrg = useCurrentPlatformAssistantStore.use.byOrg();
const setAssistantIdAction = useCurrentPlatformAssistantStore.use.setAssistantId();

Both use .use.field() from createSelectors. No useShallow, no bare useStore(). createSelectors wraps the base store correctly at the bottom of the store file.

Gate 5 — getState() in non-React code

getAssistantId uses get().byOrg[orgId] inside the store factory — that's Zustand's get, not a hook. Correct.


perOrgStorage adapter

The custom StateStorage translates persist's single-name interface into per-org vellum_current_assistant_id__{orgId} keys. The two-loop pattern in writeByOrgToLocalStorage (collect first, then delete/write) is safe — no iteration-while-mutating. partialize: (state) => ({ byOrg: state.byOrg }) correctly excludes actions from serialization. Backward-compatible on-disk format preserved.

Double JSON round-tripcreateJSONStorage(() => perOrgStorage) will stringify the state envelope before passing to perOrgStorage.setItem, which then parses it to extract byOrg. Redundant serialize/deserialize but harmless.

Cross-tab sync

if (event.key.startsWith(PLATFORM_ASSISTANT_STORAGE_PREFIX)) {
  void useCurrentPlatformAssistantStoreBase.persist.rehydrate();
}

Correctly calls .persist.rehydrate() on the base store (not the createSelectors-wrapped export — .persist lives on the raw store). void to suppress the Promise warning. Equivalent behavior to the deleted notify() plumbing, with less code.

setAssistantIdAction in effect deps

Zustand actions have stable references across renders — including this in the useEffect dep array is correct and won't trigger extra effect runs.

delete next[orgId]

The spread { ...state.byOrg } before the delete creates a new object — no mutation of existing state.

CI — 7/7 green. ✅


Net: 69 lines added, 69 lines deleted from the hook (-50 lines net including the new store file is actually +168 / -69). The useSyncExternalStore + module-level pub/sub is gone; the store is cleaner and consistent with organization-store.ts and auth-store.ts.

@vex-assistant-bot
Copy link
Copy Markdown
Contributor

@codex review
@devin review this PR

@ashleeradka ashleeradka merged commit 3e19a77 into main May 21, 2026
7 checks passed
@ashleeradka ashleeradka deleted the claude/refactor-platform-assistant-hook-DRjFS branch May 21, 2026 02:36
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +25 to +26
const setAssistantIdAction =
useCurrentPlatformAssistantStore.use.setAssistantId();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Action obtained via .use.setAssistantId() subscription instead of .getState(), violating documented store convention

The hook subscribes to the setAssistantId action via useCurrentPlatformAssistantStore.use.setAssistantId() at line 26 and adds it to the dependency arrays of both useEffect (line 63) and useCallback (line 71). Per docs/STATE_MANAGEMENT.md, the convention table explicitly says actions should be called via useStore.getState().actionName() "anywhere" because "actions are stable references — calling via .getState() is always correct and avoids adding the action to dependency arrays." The create-selectors.ts:11 utility docstring reiterates: .getState().field for event handlers, callbacks, effects. This also creates an unnecessary React subscription to the action function. The fix is to remove the .use.setAssistantId() call and instead call useCurrentPlatformAssistantStore.getState().setAssistantId(orgId, resolvedId) directly inside the useEffect and useCallback bodies.

Prompt for agents
In apps/web/src/domains/settings/hooks/use-current-platform-assistant.ts, the setAssistantId action is obtained via useCurrentPlatformAssistantStore.use.setAssistantId() at the render body level (line 25-26), creating an unnecessary subscription. Per the STATE_MANAGEMENT.md convention, actions should be called via .getState() to avoid subscriptions and dependency array entries.

To fix:
1. Remove lines 25-26 (the const setAssistantIdAction = useCurrentPlatformAssistantStore.use.setAssistantId() assignment)
2. In the useEffect body (line 55), replace setAssistantIdAction(orgId, resolvedId) with useCurrentPlatformAssistantStore.getState().setAssistantId(orgId, resolvedId)
3. Remove setAssistantIdAction from the useEffect dependency array (line 63)
4. In the useCallback body (line 69), replace setAssistantIdAction(orgId, id) with useCurrentPlatformAssistantStore.getState().setAssistantId(orgId, id)
5. Remove setAssistantIdAction from the useCallback dependency array (line 71)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return { byOrg: next };
}),

getAssistantId: (orgId) => get().byOrg[orgId] ?? null,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 getAssistantId action is defined but unused — intentional API surface

The getAssistantId action (current-platform-assistant-store.ts:45,140) is defined in the store interface and implementation but never called anywhere in the codebase. The hook at use-current-platform-assistant.ts:28 reads byOrg directly instead. This was considered as potential dead code per the AGENTS.md dead-code-removal rule, but since this is a new store with a public API, getAssistantId serves as a convenience for non-React consumers (e.g., useCurrentPlatformAssistantStore.getState().getAssistantId(orgId)). Leaving it is reasonable, but if no other consumer is planned, removing it would follow the repo's "proactively remove unused code" guideline.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: af53e38110

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +79 to +83
for (const key of existing) {
const orgId = key.slice(PLATFORM_ASSISTANT_STORAGE_PREFIX.length);
if (byOrg[orgId] == null) {
window.localStorage.removeItem(key);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid deleting other-org keys during persist writes

writeByOrgToLocalStorage treats the in-memory byOrg snapshot as authoritative and removes every prefixed localStorage key not present in that snapshot. In multi-tab usage, a stale tab can write before its storage-triggered rehydrate() runs, causing it to delete assistant selections another tab just wrote for different orgs. The prior implementation only touched the active org key, so this introduces a lost-update regression under concurrent tab writes.

Useful? React with 👍 / 👎.

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.

2 participants