Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e67ab35
feat(webview): durable per-view viewStates pipeline (N2)
easonLiangWorldedtech Oct 28, 2026
f28c486
Merge upstream/main into vps2/n2-viewstates-durability
easonLiangWorldedtech Oct 29, 2026
7906df6
test(webview): carry the identity and durable viewStates tests with N2
easonLiangWorldedtech Oct 29, 2026
f560872
chore: untrack generated husky hooks
easonLiangWorldedtech Oct 29, 2026
6340998
chore: restore main tracked husky hooks
easonLiangWorldedtech Oct 29, 2026
1357d5b
chore: strip BOM and fix lint imports
easonLiangWorldedtech Oct 29, 2026
c59f82f
chore: untrack generated husky hooks again
easonLiangWorldedtech Oct 29, 2026
afa9ab3
fix(webview): address review findings on view-state durability
easonLiangWorldedtech Oct 29, 2026
86c0288
fix(config): keep a newer cached value when an older global-state wri…
easonLiangWorldedtech Oct 30, 2026
576f452
chore: tighten the new review-fix comments
easonLiangWorldedtech Oct 30, 2026
b4ca1bc
fix(config): advance write generations on reset
easonLiangWorldedtech Oct 30, 2026
ebe5a6a
chore: align the shared view-identity docs with N1
easonLiangWorldedtech Oct 30, 2026
f79870c
chore: tighten the reset-generation comments
easonLiangWorldedtech Oct 30, 2026
d52cd47
chore: tighten the N2 test comments
easonLiangWorldedtech Oct 30, 2026
7e49dae
Merge remote-tracking branch 'upstream/main' into HEAD
easonLiangWorldedtech Oct 30, 2026
f652d7e
chore: drop the now-unused buffer-clear helper
easonLiangWorldedtech Oct 30, 2026
7a48eed
chore: tighten the shared view-state docs
easonLiangWorldedtech Oct 30, 2026
00c0f8d
chore: tighten the N2 comments
easonLiangWorldedtech Oct 30, 2026
5a6693b
chore: tighten the N2 comments further
easonLiangWorldedtech Oct 30, 2026
9a6521d
merge upstream/main (a4f774b17) into vps2/n2-viewstates-durability
easonLiangWorldedtech Nov 2, 2026
96d2957
Merge upstream/main (e943530c8) into vps2/n2-viewstates-durability
easonLiangWorldedtech Nov 5, 2026
3f57651
fix(config): end write ownership on reset, and carry the profile name…
easonLiangWorldedtech Nov 5, 2026
f3ca33b
fix(config): settle in-flight secret writes before a reset ends their…
easonLiangWorldedtech Nov 5, 2026
515cc02
fix(config): refuse secret writes that begin inside a reset instead o…
easonLiangWorldedtech Nov 5, 2026
0a77cf8
test(webview): cover both halves of the sibling task-handler rebuild …
easonLiangWorldedtech Nov 5, 2026
ddda5ce
test(webview): pin what each store holds when a registration write fails
easonLiangWorldedtech Nov 5, 2026
32d9a10
fix(config): clear the reset guard when the reset itself fails
easonLiangWorldedtech Nov 5, 2026
ab70702
fix(config): single-flight the reset so an overlapping caller cannot …
easonLiangWorldedtech Nov 5, 2026
752e393
test(config): drop the matcherless expect.soft from the pending-secre…
easonLiangWorldedtech Nov 5, 2026
af27da2
chore(ci): re-run checks after the webview locale-loading flake
easonLiangWorldedtech Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,49 @@ describe("alwaysDenyUnapprovedCommands global setting", () => {
})
})

describe("viewStates global setting", () => {
it("parses a valid per-view record", () => {
const parsed = globalSettingsSchema.parse({
viewStates: {
"stable-view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1730000000000 },
"stable-view-2": {},
},
})
expect(parsed.viewStates?.["stable-view-1"]).toEqual({
mode: "code",
currentApiConfigName: "profile-a",
updatedAt: 1730000000000,
})
expect(parsed.viewStates?.["stable-view-2"]).toEqual({})
expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates")
})

it("parses an entry that carries the host-minted claim token", () => {
// The claim binds the entry to the webview that wrote it; a legacy entry without one
// still parses, because refusing it is setViewStateId's job, not the schema's.
const parsed = globalSettingsSchema.parse({
viewStates: {
"stable-view-1": { mode: "code", updatedAt: 1730000000000, claimToken: "7f9c-host-minted" },
"stable-view-2": { mode: "code" },
},
})
expect(parsed.viewStates?.["stable-view-1"]?.claimToken).toBe("7f9c-host-minted")
expect(parsed.viewStates?.["stable-view-2"]).toEqual({ mode: "code" })
})

it("rejects invalid field values inside a view entry", () => {
expect(() => globalSettingsSchema.parse({ viewStates: { v: { mode: 42 } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { currentApiConfigName: 42 } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { updatedAt: "soon" } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { claimToken: 42 } } })).toThrow()
})

it("rejects non-object viewStates values", () => {
expect(() => globalSettingsSchema.parse({ viewStates: "not-a-record" })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: "not-an-entry" } })).toThrow()
})
})

describe("collapseCommandOutputByDefault global setting", () => {
it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => {
const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value })
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { GLOBAL_STATE_KEYS } from "../index.js"

describe("GLOBAL_STATE_KEYS", () => {
it("should contain registered durable per-view state", () => {
expect(GLOBAL_STATE_KEYS).toContain("viewStates")
})

it("should contain provider settings keys", () => {
expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled")
})
Expand All @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => {

it("should not contain secret state keys", () => {
expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey")
expect(GLOBAL_STATE_KEYS).not.toContain("apiKey")
})

it("should contain OpenAI Compatible base URL setting", () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Persisted non-secret selections for a stable webview instance.
*/
export const viewStateSchema = z.object({
mode: z.string().optional(),
currentApiConfigName: z.string().optional(),
updatedAt: z.number().optional(),
/** Host-minted token binding this entry to the webview that wrote it. */
claimToken: z.string().optional(),
})

/**
* GlobalSettings
*/
Expand All @@ -122,6 +133,7 @@ export const globalSettingsSchema = z.object({
currentApiConfigName: z.string().optional(),
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
viewStates: z.record(z.string(), viewStateSchema).optional(),

lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
Expand Down
11 changes: 11 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export interface ExtensionMessage {
| "originalContent"
| "rooHistoryImportProgress"
| "themeFixtureProbeRequest"
| "viewStateClaim"
| "diagnosticsRequest"
text?: string
/** For fileContent: { path, content, error? } */
Expand Down Expand Up @@ -171,6 +172,13 @@ export interface ExtensionMessage {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values?: Record<string, any>
requestId?: string
/**
* For `viewStateClaim`: the durable `viewStates` key the delivered claim governs. The
* message is only ever sent to the webview that key belongs to.
*/
viewStateId?: string
/** For `viewStateClaim`: the host-minted token the webview stores and echoes on launch. */
viewStateClaim?: string
themeFixture?: WebviewThemeFixture
diagnostics?: WebviewDiagnosticsSnapshot
promptText?: string
Expand Down Expand Up @@ -712,6 +720,9 @@ export interface WebviewMessage {
| "themeFixtureProbeResponse"
| "diagnosticsResponse"
text?: string
viewStateId?: string
/** For `webviewDidLaunch`: the claim delivered for `viewStateId`, enforced by setViewStateId. */
viewStateClaim?: string
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
taskId?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
89 changes: 77 additions & 12 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({
taskHistory: true,
listApiConfigMeta: true,
currentApiConfigName: true,
// Per-view selection state is machine-local: it keeps flowing through the
// normal runtime and pruning paths but must not transfer between settings.
viewStates: true,
})

export class ContextProxy {
Expand All @@ -78,6 +81,7 @@ export class ContextProxy {

constructor(context: vscode.ExtensionContext) {
this.originalContext = context

this.stateCache = {}
this.secretCache = {}
this._isInitialized = false
Expand Down Expand Up @@ -397,6 +401,18 @@ export class ContextProxy {
*/
private readonly stateWriteOwner = new Map<string, object>()
private readonly secretWriteOwner = new Map<string, object>()
/**
* The secret writes that are still in flight, each paired with the claim it took,
* so a reset can wait for them to settle before it deletes the keys. Without the
* pairing a reset cannot tell a settled-but-orphaned write from a live one.
*/
private readonly pendingSecretWrites = new Map<string, Set<{ settle: Promise<void> }>>()
// A reset is in progress: the caches are meaningless mid-reset, and a secret write begun
// inside it would commit after the delete pass and survive the erase.
private resetting = false
// The reset pass currently in flight, if any: single-flight so that two callers cannot each run a
// delete pass and have whichever finishes first lower the guard under the other.
private resetInFlight: Promise<void> | null = null
/** How many writes of each secret were started. Unlike the owners, it is not cleared when a write is over. */
private readonly secretWriteCount = new Map<string, number>()

Expand Down Expand Up @@ -443,6 +459,13 @@ export class ContextProxy {
}

storeSecret(key: SecretStateKey, value?: string) {
if (this.resetting) {
// Refused rather than deferred: a deferred secret write would re-store exactly the
// value the user asked to erase, and a silently dropped one would lose the save.
return Promise.reject(
new SettingsWriteError([], [key], new Error("secret writes are refused while state is being reset")),
)
}
// Update cache.
const owner = {}
this.secretWriteOwner.set(key, owner)
Expand All @@ -454,7 +477,19 @@ export class ContextProxy {
// cache (it stays empty until the next write, `refreshSecrets` or a restart) rather than being served.
// The entry of the key is removed when the write and its read-back are over (if it is still the latest). A store
// that throws at once is turned into a rejection, so it is handled the same.
const pending = this.pendingSecretWrites.get(key) ?? new Set<{ settle: Promise<void> }>()
let settleWrite: () => void = () => {}
const entry: { settle: Promise<void> } = {
settle: new Promise<void>((resolve) => {
settleWrite = resolve
}),
}
pending.add(entry)
this.pendingSecretWrites.set(key, pending)
const release = () => {
pending.delete(entry)
if (pending.size === 0) this.pendingSecretWrites.delete(key)
settleWrite()
if (this.secretWriteOwner.get(key) === owner) this.secretWriteOwner.delete(key)
}
const stored = (async () =>
Expand Down Expand Up @@ -715,18 +750,48 @@ export class ContextProxy {
* This clears all data from both the in-memory caches and the VSCode storage.
* @returns A promise that resolves when all reset operations are complete
*/
public async resetAllState() {
// Clear in-memory caches
this.stateCache = {}
this.secretCache = {}

await Promise.all([
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)),
])

await this.initialize()
public resetAllState(): Promise<void> {
if (this.resetInFlight) {
// A caller that arrives mid-reset joins the pass already running rather than starting a
// second delete pass whose end could lower the guard while the first was still writing.
return this.resetInFlight
}
this.resetting = true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const pass = (async () => {
try {
// A secret write that is in flight at this moment has already committed nothing, but it will
// commit as soon as it settles, and the deletes below cannot un-commit a write that lands
// after them. Wait for the pending writes first, then end the claims, then delete, so the
// cleared copy is the last one to land in storage and the cache stays empty afterwards.
const inFlight = [...this.pendingSecretWrites.values()].flatMap((writes) =>
[...writes].map((w) => w.settle),
)
await Promise.allSettled(inFlight)

// Clear in-memory caches
this.stateCache = {}
this.secretCache = {}
// A reset also ends write ownership: a write that is still in flight reads the Memento back
// into the cache it just lost, so clearing the caches is not enough to keep pre-reset state
// out. Ownership is what the rejection handlers gate on.
this.stateWriteOwner.clear()
this.secretWriteOwner.clear()

await Promise.all([
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)),
])

await this.initialize()
this.resetting = false
} finally {
this.resetting = false
this.resetInFlight = null
}
})()
this.resetInFlight = pass
return pass
}

private static _instance: ContextProxy | null = null
Expand Down
15 changes: 14 additions & 1 deletion src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export const providerProfilesSchema = z.object({

export type ProviderProfiles = z.infer<typeof providerProfilesSchema>

/** Already-deleted profile: callers branch on this type instead of matching message text. */
export class ProviderSettingsNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = "ProviderSettingsNotFoundError"
}
}

export class ProviderSettingsManager {
private static readonly SCOPE_PREFIX = "roo_cline_config_"
private readonly defaultConfigId = this.generateId()
Expand Down Expand Up @@ -511,7 +519,7 @@ export class ProviderSettingsManager {
const providerProfiles = await this.load()

if (!providerProfiles.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
throw new ProviderSettingsNotFoundError(`Config '${name}' not found`)
}

if (Object.keys(providerProfiles.apiConfigs).length === 1) {
Expand All @@ -522,6 +530,11 @@ export class ProviderSettingsManager {
await this.store(providerProfiles)
})
} catch (error) {
// A missing config is a caller-meaningful signal, not a failure: rethrow it
// unwrapped so callers can branch on the type instead of message text.
if (error instanceof ProviderSettingsNotFoundError) {
throw error
}
throw new Error(`Failed to delete config: ${error}`)
}
}
Expand Down
Loading
Loading