Repository navigation
test(webview): ClineProvider parallelMode suite with viewStates pruning edges - #1921
easonLiangWorldedtech wants to merge 55 commits into
Conversation
…en view-identity tests Track the in-flight tab panel creation with a module-level promise so concurrent openClineInNewTab calls reuse one panel and provider (adds a Promise.all regression test). ClineProvider.spec sets the private view via the public resolveWebviewView() instead of a ts-ignore assignment. registerCommands.spec types evictCurrentTask/refreshWorkspace on the fixture and drops the as any attachment. eslint-suppressions: prune the registerCommands.spec.ts entry (two as any suppressions removed).
…n the concurrency assertion
…-bar posts - openClineInNewTab: extract the unserialized creation body into createTabPanelUnlocked and guard the in-flight slot clear so a settled creation cannot clobber a replacement already stored in the slot. - onDidDispose: clear the tracked tab ref only when the disposing panel is still the tracked one, so a late disposal of a replaced panel cannot clobber the replacement's ref. - MDM lookup failure: log the fallback to the output channel instead of swallowing it silently. - Route the six title-bar button handlers through a shared postActions helper that posts each action in order and logs failures with the handler-specific prefix. - package.json: add the four InTab commands to the command palette, scoped to the active tab panel. - Tests: handler-level regression for openInNewTab + popoutButtonClicked started before the first creation resolves; fresh-creation test for a settled in-flight promise; stale-panel disposal regression; retained panel assertion for disposed tab instances; rightmost-editor column placement assertion; MDM fallback output assertion; %s placeholders for primitive it.each titles. - Stryker directives for the two equivalent setPanel type-literal mutants (setPanel branches only on type === sidebar).
Replace the weak toBeDefined() assertion in the dispose spec with an identity check against the panel returned during creation, per the CodeRabbit actionable comment on this PR (review run 7c4cfeb3-6dd9-4615- 9a58-70cfc705eca2). The tracked tab is now pinned with toBe(panel) before the dispose assertions, so a wrong or duplicated tracked panel fails the suite instead of passing a defined-only check. Upstream: Zoo-Code-Org#1528 (vps2 F0)
Retain the tracked tab panel in the InTab handler cases and assert that getInstanceForView was called with that exact panel, per the CodeRabbit actionable comment on this PR (review run 4afe1273-8739-4235-90d3-311db5f6ccb9, inline comment 3952466254 on the tabHandlerCases spec). A handler resolving any other view now fails instead of passing on the stubbed provider result alone; the same identity pin is applied to plusButtonClickedInTab. Upstream: Zoo-Code-Org#1528 (vps2 F0)
…States Each ClineProvider instance now owns a unique viewId (renderContext plus a monotonic counter) and registers a stable viewStateId for durable persistence. - Per-view state buffer (viewLocalState) holds mode / currentApiConfigName / apiConfiguration overrides in memory; saveViewState persists the non-secret subset durably under the active view id, rekeyed to the stable id on registration. - viewStates is stored as a map pruned to the newest 50 entries; writes go through a serialized queue so concurrent provider instances merge without lost updates. - setViewStateId sanitizes ids and rejects "__proto__" so a per-view entry can never be keyed through the Object.prototype setter. - postMessageToWebview no longer awaits the webview ack: a remounted or disposed page never acknowledges, and awaiting would wedge task-critical callers. - History restore falls back to the default mode view-locally instead of writing the shared global mode. - GlobalState gains the "viewStates" key and GLOBAL_STATE_KEYS tracks it. Adds F1a coverage in ClineProvider.spec.ts (viewId uniqueness, saveViewState persistence semantics, loadViewState fallback and failure, pruning, the __proto__ guard) and adapts the two history-restore tests in ClineProvider.sticky-mode.spec.ts to the view-local restore. getState() merging of hydrated per-view values and the remaining view-state suites land in the follow-up (F1b).
…lude viewStates from settings transfer
…ions through the view-local buffer
…nd target tab-instance commands Reapply in-flight view-local fields with Object.is identity so a field cleared during the load window stays cleared; route mode switches through setValue so the in-memory buffer and durable write agree, with rollback on failure; refresh cross-instance view-local state on profile upsert, activate and delete and re-pin the buffer after a delete; point focusInput and active-panel re-registration at the tracked tab provider and panel; log dropped webview postMessage failures with the message type; pin tab-instance, focusInput and active-panel identity in the registerCommands tests and type the mdm double in the provider spec.
…apture view pin on delete Address CodeRabbit walkthrough findings on the F1a unit: - handleModeSwitchUnlocked now bails before the task-level writes when the abort signal has fired, closing the partial-apply window where a cancelled switch could still rewrite the persisted task mode; the existing pre-write guard still covers in-flight aborts. - Replace bracket access to sibling-instance private members with a typed pinnedProfileName getter and direct private member access (compile-time safe across instances). - deleteProviderProfile now captures this view's pin before the currentApiConfigName rewrite so a view pinned to the deleted profile while the global selection points elsewhere is still reconfigured with the surviving profile's settings. Tests: focusInput asserts the tab panel by identity and that no error was logged on the success path; the stalled getProfile double fails loudly on a second lookup (only one lookup is resolvable).
…-local profile pins
handleModeSwitchUnlocked: an abort landing while updateTaskHistory is in flight previously left the new mode persisted in task history and assigned to task._taskMode before the pre-write signal check bailed; the landed write is now rolled back to the pre-switch item and the method returns before the TaskModeSwitched emit and the durable mode write. TaskModeSwitched now only fires for a completed transition. deleteProviderProfile: the unconditional setValue('currentApiConfigName', ...) overwrote a view's pin when an unrelated profile was deleted; the pin is now re-pointed only when it names the deleted profile, and a deleted-was-global deletion updates the shared store only. The nested apiConfiguration overlay is replaced with the surviving profile's settings only for a view pinned to the deleted profile.
…tore deleteProviderProfile pruned the UI-facing listApiConfigMeta entry but never removed the profile's settings from the ProviderSettingsManager store (context.secrets), so a later listApiConfigMeta sync could resurrect the deleted profile and a dangling per-mode mapping could re-activate it. The purge now calls providerSettingsManager.deleteConfig and branches on the typed ProviderSettingsNotFoundError (introduced here alongside) so an already-gone secret is an idempotent success -- the stale list entry is still pruned -- while any other failure (e.g. the store refusing to delete the last remaining configuration) propagates. Matching message text instead would let a profile whose name contains 'not found' swallow an unrelated failure. Tests: the dangling-mode-mapping resurrection scenario, the already-gone secret, the store-level last-profile refusal, the typed-signal contract in the manager spec, and provider-level not-found/propagation pins.
A failed task-history rollback during an aborted mode switch previously propagated into the outer persistence-error handler and surfaced as the switch's own persistence failure. Guard the rollback with its own try/catch so the rollback error is logged with task context and the cancellation return is preserved (CodeRabbit finding on this PR).
…d switch The in-flight abort rollback rewrote the whole task-history item with the pre-switch snapshot, clobbering any fields the running task persisted during the pending window (tokens, cost, status, apiConfigName). Re-read the item and restore only the mode this switch changed (CodeRabbit data-integrity finding on this PR).
Rebasing onto main tip merged main's createTabPanelUnlocked body with the PR's serialized creation. Main no longer resolves CodeIndexManager in this function, so the leftover line referenced an import the PR never added and the suite failed with ReferenceError. Removing it restores the PR's own delta: 48 registerCommands tests and 459 F1a tests pass.
…overrides Fold ClineProvider viewLocalState on top of ContextProxy values in getState() (mode, apiConfiguration, and all per-view fields) so each webview reports its own selections while falling back to shared global state for everything else. Ports the getState-merging and local-state-isolation spec coverage from the superseded vps2 source. Also pins the full default surface of the merged read path, including the apiConfiguration provider fill-in when provider settings sanitize the raw value away (mutation-diff gate).
Rebuilding the F1b unit as main tip + its own delta cleared the conflict with main. The patch was authored against an older main, so applying it dropped the alwaysDenyUnapprovedCommands entry from getState(); restoring it with the shared default constant keeps the settings round trip complete. 410 tests pass.
Applying the F1c delta (ad94239...8554307) onto the rebuilt F1b head cleared the conflict with main tip without changing content. 338 src tests and 39 webview-ui tests pass.
Part of the vps2 durable per-view state series, tracked in #41. Stacked on F2b. Content ported from the pinned source (kind: commit, base 8554307 -> head bff2a5c, PR Zoo-Code-Org#1555). Over the soft 400 a+d cap (739): one cohesive regression suite whose ~588 lines are shared mock setup for three describes.
…ompensating deletion setViewStateId took the identifier straight off the webview channel and used it as a key in the shared viewStates map. It now treats it as untrusted input: - a non-string value is logged and ignored (a crafted postMessage is JSON, so the declared message type is not enforced at the boundary); - the key is length-bounded by MAX_VIEW_STATE_ID_LENGTH (the webview generates a UUID, so a far longer value is not a view id, and an unbounded key is unbounded growth in the shared map); - an id another live ClineProvider already registered is refused: adopting it would load that view's pinned profile, nested overlay, and mode into this provider, and this webview could then overwrite that view's persisted entry. The view keeps its temporary id and still works against the shared state. Generating the id webview-side is unchanged and deliberate: it has to survive a webview reload, which recreates the view on both sides, so the host cannot mint it. What the host owns is which view may hold a given key. A webview can already request any profile through the activateProviderProfile message path, so the id was never an authentication boundary; the boundary violation is one live view taking over another live view's persisted entry, and that is what the claim check blocks. Zoo-Code-Org#1921 is not cumulative with Zoo-Code-Org#1919, so the profile-deletion work is ported here too: the compensating deleteProviderProfile (pre-delete settings read, per-store snapshots, per-store rollback, inconsistent-state error) plus the typed ProviderSettingsNotFoundError out of getProfile that the compensation branches on. Tests: three new in ClineProvider.spec (foreign claim refused, oversized id refused, non-string id ignored) and the three delete-profile regression tests; one in ProviderSettingsManager.spec. Three existing reload tests now dispose the writing provider before the new one re-registers the id, which is what a reload does. Negative controls: neutralising each gate (claim predicate -> false, length cap -> false, typeof gate -> false) fails its test (foreign id adopted, oversized key adopted, TypeError on trim); each was reverted and re-passed. Local: core/webview + core/config 972 passed; tsc 100 errors (= local baseline); eslint --prune-suppressions --max-warnings=0 clean on all four files.
Pushed
|
| gate | behaviour |
|---|---|
| type | a non-string value is logged and ignored — a crafted postMessage is JSON, so the declared viewStateId?: string is not enforced at the boundary |
| length | bounded by MAX_VIEW_STATE_ID_LENGTH (64) — the webview generates a UUID, so a far longer value is not a view id, and an unbounded key is unbounded growth in the shared map |
| ownership | an id another live ClineProvider already registered is refused; the view keeps its temporary id |
Why ownership rather than host-side generation: the id has to survive a webview reload, which recreates the view on both sides, so the host cannot mint an id that persists across recreation — that is the F1c design in #41. A webview can already request any profile through the activateProviderProfile message path, so the id was never an authentication boundary. The boundary that is real is one live view adopting another live view's persisted key: it would load that view's pinned profile, nested overlay and mode into this provider, and this webview could then overwrite that view's entry. getAllInstances() claim check closes exactly that, and the view still works against the shared state on its temporary id.
Persistence Integrity (the second ❌ row)
#1921 is not cumulative with #1919, so the compensating deleteProviderProfile is ported here: pre-delete settings read (abort before the destructive commit unless it is the typed not-found), snapshots of profile list / shared selection / view pin / nested overlay, per-store rollback each awaited on its own, and a distinct Profile deletion left persisted state inconsistent: … error when the rollback itself fails. rePinViewLocalStateForDeletedProfile restores each sibling's own pin before rethrowing. The typed ProviderSettingsNotFoundError out of getProfile (the dependency the compensation branches on) is ported with it.
Tests + negative controls
New: refuses a viewStateId that another live view already owns, refuses an oversized viewStateId instead of using it as a persisted key, ignores a non-string viewStateId arriving from a crafted message, the three delete-profile compensation tests, and rejects a missing profile lookup with the typed not-found error. Three existing reload tests now dispose the writing provider before the new one re-registers the id — that is what a reload actually does, and it is the case the claim check must keep allowing.
| mutation | result |
|---|---|
claim predicate → false |
foreign-claim test FAILS (viewStateId becomes owned-by-first-view) |
length cap → false |
oversized test FAILS (200-char key adopted) |
typeof viewStateId !== "string" → false |
non-string test FAILS (TypeError: viewStateId.trim is not a function) |
| manager typed-throw reverted | typed not-found test FAILS |
Local: core/webview + core/config 972 passed; tsc --noEmit 100 errors (= local baseline, stale built @roo-code/types dist — CI compile is green); eslint --prune-suppressions --max-warnings=0 clean on all four files, no suppression delta.
No @coderabbitai full review requested — the content push triggers the at-head review.
|
Tip For best results, initiate chat on the files or code changes.
You report that
The ownership check addresses live-view state isolation, not profile authorization. Reload tests must release the previous provider’s claim before registering the replacement provider. I have not independently verified this head or the reported test results. I will not trigger a separate full review, as requested. ✏️ Learnings added
🧠 Learnings usedIf you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! You are interacting with an AI system. |
…n rollback Port of the two cross-view tests added on Zoo-Code-Org#1919 (the units are not cumulative): - refreshes another live view's buffered settings when the profile it pins is upserted: a second live ClineProvider pinned to the profile must receive the fresh apiConfiguration through its own view-local buffer and a state post. - restores an affected sibling view's own pin when the deletion re-pin write fails: the sibling gets its own context (same backing store, failing viewStates update), so the re-pin write is the one that rejects; the test asserts the cached map, the durable map and the buffer all still name the pre-deletion profile, and that the deleting view rolled its own stores back. Negative controls on THIS branch: - emptying refreshViewLocalStateForUpdatedProfile's affected list -> the first test fails (sibling keeps the openrouter/stale-key overlay). - replacing the deletion's list restore with `void previousEntries` -> the second test fails (list no longer matches [doomed, keeper]). The rePin-level pin rollback itself is not separately observable here: this branch's writePersistedViewStates already repairs storage AND the ContextProxy cache when its setValue rejects, so the phantom map never survives. Both reverted byte-for-byte and re-passed. Test-only change; no production lines touched. Local: core/webview + core/config = 974 passed; tsc 100 errors (= baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
Port of the Zoo-Code-Org#1919 fix (the units are not cumulative): createTabPanelUnlocked built the tab in eight awaited steps after constructing the ClineProvider, with no cleanup on failure. A rejected step left the provider registered in ClineProvider.activeInstances with its listeners, and setPanel may already have tracked a panel nobody owns - so a later "Open in editor" inherits a dead tab or races a second provider for it. The post-construction body now runs in a try and disposeFailedTabCreation disposes the provider, clears the tracked ref only while it still names this panel, and disposes the panel - each step in its own try/catch, with an incomplete cleanup logged as its own line while the original error still reaches the caller. Tests ported as well: 'disposes the provider and the panel when a creation step rejects' and 'reports incomplete cleanup when the tab-creation rollback itself fails'. Negative controls on THIS branch: removing the disposeFailedTabCreation call fails both tests; removing only the tracked-ref clear fails the first on getPanel(). Reverted byte-for-byte and re-passed. src/activate/registerCommands.ts is now byte-identical to the Zoo-Code-Org#1919 branch version. git diff -w shows +51/-1; the rest is the one-tab re-indentation the try requires. Local: core/webview + core/config + activate = 1084 passed; tsc 100 errors (= baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
Pushed
|
…m writing Port of Zoo-Code-Org#1919 027c502. The units are not cumulative, and this branch was further behind than expected: deleteProviderProfile here still ran unserialized against upsert/activation/mode-switch, so the queue wrapper, the two queue-contract abort checks, and the new cancellation guards all land here. A timed-out deletion must not keep writing durable state after it has yielded its place in the queue: the shared selection, the shared provider settings, and other views' pins are now behind one more AbortSignal check, and the catch skips compensation entirely once the signal is aborted, because the next queued mutation may already own those stores and replaying this operation's rollback would overwrite IT. Both paths log what state was left behind. The queue still advances on the timeout-bounded caller result on purpose: advancing on the unbounded run promise lets one hung durable write block every later profile mutation for the lifetime of the window. Test: 'stops writing durable state once the deletion's abort signal fires' - the cancellation lands on the survivor lookup, after the settings commit and the profile- list write; asserts the list write landed, the shared selection and provider settings were not rewritten, and no compensation write happened. Negative controls: removing the pre-rewrite guard lets the deletion run to completion (rejection assertion fails); removing the compensation skip replays the profile list (list no longer equals [keeper]). Both reverted byte-for-byte and re-passed. Port review per the hunk-by-hunk rule: git diff shows 5 hunks, +51 lines, all additions (wrapper + signature + three abort checks + compensation skip); nothing foreign was dragged in. Local: core/webview + core/config + activate = 1085 passed; tsc 100 errors (= baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
Pushed
|
…loop Port of Zoo-Code-Org#1919 59f4492 (the units are not cumulative). The generic settings loop iterates Object.entries(message.updatedSettings) and calls provider.setValue for every key; with the per-view buffer in place that write also updates this view's local overlay, which getState() prefers over the shared values. Nothing validated the runtime shape, so a crafted payload could carry apiConfiguration straight into both sinks. HOST_OWNED_SETTINGS (apiConfiguration, listApiConfigMeta, viewStates) is rejected at the top of the loop with its own log line: those keys come from validated profile-management paths, and ClineProvider owns the per-view durable map. Test and negative control are the same as on Zoo-Code-Org#1919: removing the guard makes the apiConfiguration assertion fail; reverted byte-for-byte and re-passed here. Port diff is identical to Zoo-Code-Org#1919 (+62 across the two files), reviewed hunk-by-hunk: one Set at module scope, one guard at the top of the loop, one new describe in the spec. Local: core/webview + core/config + activate = 1086 passed; tsc 100 errors (= baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
Port of Zoo-Code-Org#1919 9e47e3e (the units are not cumulative). dispose() set _disposed, ran a chain of awaited teardown steps, and only removed the provider from activeInstances on its last line: a rejecting mcpHub.unregisterClient() / skillsManager.dispose() / marketplaceManager.cleanup() left the provider discoverable through getVisibleInstance, getAllInstances and getInstanceForView with its view and listeners attached, and _disposed meant the cleanup would never run again. Every teardown step now runs through attemptCleanup, which records its own failure and lets the rest proceed; the unregistration is unconditional afterwards and an incomplete teardown gets its own log line. Test and negative control are the same as on Zoo-Code-Org#1919: making attemptCleanup propagate makes dispose() throw at the first failing step and the provider stays registered; reverted byte-for-byte and re-passed here. Port diff is identical to Zoo-Code-Org#1919 (+67/-8 across the two files), reviewed hunk-by-hunk: one block inside dispose() plus the new test. Nothing foreign. Local: core/webview + core/config + activate = 1087 passed; tsc 100 errors (= baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
Pushed
|
…eview nits Port of the same defect found on the sibling unit (Zoo-Code-Org#1919): the branches are not cumulative, so this unit needs its own copy. deleteProviderProfileUnlocked rewrites five durable stores but compensated only four. When the deleted profile was the global or view-local selection, contextProxy.setProviderSettings(survivingSettings) replaces the shared provider keys; if a later step failed (e.g. a sibling re-pin), the rollback restored the settings, the profile list, the shared selection and this view's pin - but left the survivor's provider keys in place, so the restored profile's name was served with another profile's provider and credentials through getState(), while the success log still claimed a complete restore. The snapshot + providerSettingsWriteLanded flag now restore those keys like the other four. Test: 'restores the shared provider settings when a step after the rewrite fails' - the failure is injected in rePinViewLocalStateForDeletedProfile, after the keys landed. Negative control: removing the compensation block leaves apiProvider 'anthropic' where 'openrouter' must be; reverted byte-for-byte and re-passed. Nits: mockRestore() on a bare vi.fn() leaves executeCommand with no implementation at all, so restore the saved previousExecute in a finally instead; and the closing `): Promise<void> {` of disposeFailedTabCreation / deleteProviderProfileUnlocked was one tab deeper than its declaration. Local: core/webview + core/config + activate = 1088 passed; tsc 100 errors (= baseline); eslint --prune-suppressions --max-warnings=0 clean on all four files.
Review nit: the deletion test asserted that the ask-mode mapping still points at the
deleted id and that the id is absent from the store, but never drove the path the comment
described, so a regression in the mode-switch fallback would have passed. The test now
calls the public handleModeSwitch("ask") after the deletion and asserts the view still
selects the surviving profile.
Discrimination check: skipping the deleteProviderProfile call flips the scenario (the
mapping assertion and the post-switch selection both go red), so the assertions track the
deletion rather than the seed. Reverted byte-for-byte; 18/18 in this file, eslint clean.
Port of the same regression-evidence fix on the sibling unit (Zoo-Code-Org#1919 34d0907); the branches are not cumulative. getProfile now raises and preserves ProviderSettingsNotFoundError for id lookups too, but the focused test only covered the name branch. The id branch is the one the mode mapping and deleteProviderProfile hit - a stale mode -> id mapping has to surface as a prunable not-found, not as a generic failure that gets re-wrapped. Test seeds two profiles with known ids, calls getProfile({ id: 'missing-id' }) and asserts the error type plus the id-specific message. Negative control: swapping the id branch to a plain Error turns the typed assertion red; reverted byte-for-byte and re-passed. Local: core/config suite 254 passed, eslint --prune-suppressions --max-warnings=0 clean.
…fails Port of the same fix on the sibling unit (Zoo-Code-Org#1919 fe87309); the branches are not cumulative. rePinViewLocalStateForDeletedProfile wrote all affected views through Promise.all and only ever undid the FAILING sibling, so a rejection in one view left the views whose writes had landed pinned to the surviving profile while the deletion itself was rolled back. Every affected view is now snapshotted before any write starts, the writes are awaited with Promise.allSettled, and on any rejection every affected view is restored (each restore awaited on its own). The restore is unconditional because _saveViewLocalStateFromMutation fills the in-memory buffer before the durable write settles, so even a rejected write leaves the view re-pointed. The first rejection is rethrown so the caller's shared-store compensation still runs. Test: 'rolls back the sibling views whose re-pin write succeeded when another sibling fails'. Negative control: restoring only the last snapshot leaves the healthy sibling on 'keeper-profile' (new test red, pre-existing single-sibling test green); reverted byte-for-byte. Local: core/webview + core/config + activate = 1090 passed; tsc 100 (= baseline); eslint clean.
…he payload, not on call order The test armed the durable failure with mockRejectedValueOnce on contextProxy.setValue. This flow writes the viewStates map more than once (the registration re-key, then the mode write), so the Once pins whichever call happens to come first - and if it pins nothing at all the test still passes green while claiming coverage of the durable-failure contract. The mock is now conditioned on the payload: it rejects only when the key is viewStates and this view's entry carries mode 'architect', delegating every other call to the real setValue. A rejectedKeys array records what actually failed and the test asserts it contains viewStates, so any future revert to order-based mocking makes the test report itself instead of passing silently. Negative control follows the propagation, not the injection: reordering _saveViewLocalStateFromMutation so the buffer updates first and the durable error is swallowed turns the test red (promise resolved "undefined" instead of rejecting). Reverted byte-for-byte and green again. Note: the key comparison uses String(key) because viewStates is not part of the typed setValue key union (the same union gap behind the pre-existing TS2345s in this file). Local: core/webview 718 passed (f2a) / 726 passed (f2c); tsc at baseline; eslint clean.
Part of the vps2 durable per-view state series — tracked in easonLiangWorldedtech#41.
Unit F2c of the F2 split; supersedes #1555. Stacked on F2b. Content source of record:
kind: commit, base8554307ec-> headbff2a5ca8(PR #1555).Budget: 739 a+d standalone — over the soft 400 cap, under the hard 1000 cap. Rationale: one cohesive regression suite whose ~588 lines are shared mock setup for three describes; splitting the file would duplicate the setup rather than reduce what a reviewer reads.
Related GitHub Issue
Closes: easonLiangWorldedtech#41
Description
How this unit implements durable per-view state, and the choices reviewers should check:
ClineProviderstarts with a host-generated temporary id (sidebar-3) and, onwebviewDidLaunch, adopts the stable id the webview persists throughacquireVsCodeApi().setState()(webview-ui/src/utils/vscode.ts). The id is sanitized to[A-Za-z0-9_-]and__proto__is rejected so it stays a safe object key in the shared map.viewStatesmap.modeandcurrentApiConfigNameare stored per view id under the newviewStatesglobal-state key (packages/types/src/global-settings.ts). Every write goes through one serialized write queue that re-reads the map fresh and merges into the existing entry, so concurrent views cannot clobber each other; entries with nothing persistable are deleted and stale entries are pruned.getState(). The view-local buffer is layered over the sharedContextProxyvalues; fields mutated while the async profile lookup is in flight are re-applied field by field, so a slow load cannot clobber a newer user selection.ContextProxy#updateGlobalStatefills the in-memory cache before awaitingglobalState.update, so the threeviewStateswrites now go throughwritePersistedViewStates, which restores the previously cached map when the storage write rejects instead of leaving other views reading an uncommitted map.viewStatesis excluded from settings import/export so a per-window view map is never transferred between machines.src/activate/registerCommands.ts): title-bar commands target the click-origin instance, and overlappingopenClineInNewTabcalls are serialized.Test Procedure
ClineProvider.parallelMode.spec.ts— multi-tab isolation: two views with different ids keep separatemode, profile pin andapiConfiguration.ClineProvider.spec.ts→setViewStateId,persisted view state pruning,view state persistence edge cases— merge/clear/prune semantics, corrupted storage values treated as an empty map, re-keying of pre-launch writes, and the cache rollback when the storage write rejects.ClineProvider.sticky-mode.spec.ts/sticky-profile.spec.ts— a view's pinned profile survives a mode switch and a sibling view's change.vscode.spec.ts/ExtensionStateContext.spec.tsx— the webview reuses its persisted id or mints a new one, and posts it inwebviewDidLaunch.Pre-Submission Checklist
Documentation Updates
Additional Notes
mutation-diffreports more than the 500 changed-executable-line cap for this series; the remedy is tracked on easonLiangWorldedtech#41 (maintainer-side cap decision), not another split.