Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
9849018
feat(webview): view-local buffer reads and setValue persistence (N3)
easonLiangWorldedtech Oct 29, 2026
7b610a1
feat(webview): setValues batch persistence and reset cleanup (N4)
easonLiangWorldedtech Oct 29, 2026
7de336e
chore: untrack generated husky hooks
easonLiangWorldedtech Oct 29, 2026
f8acc17
feat(webview): per-view ack, history and profile restore writes (N5)
easonLiangWorldedtech Oct 29, 2026
73ef3ae
chore: strip BOM and fix lint imports
easonLiangWorldedtech Oct 29, 2026
d2a9244
fix(webview): keep main resolve/history handler versions and align st…
easonLiangWorldedtech Oct 29, 2026
692a818
chore: prune stale lint suppressions for sticky-mode spec
easonLiangWorldedtech Oct 29, 2026
cb37bbc
fix(webview): address review findings on view-state durability
easonLiangWorldedtech Oct 29, 2026
5b8c639
fix(config): keep a newer cached value when an older global-state wri…
easonLiangWorldedtech Oct 30, 2026
709f116
fix(webview): address review findings on pruning, mode-switch and pro…
easonLiangWorldedtech Oct 30, 2026
9f2ab6f
chore: restore the main-tracked husky hooks
easonLiangWorldedtech Oct 30, 2026
23054c9
chore: tighten the new review-fix comments
easonLiangWorldedtech Oct 30, 2026
17c6711
chore: tighten the shared review-fix comments
easonLiangWorldedtech Oct 30, 2026
69abf70
fix(config): advance write generations on reset
easonLiangWorldedtech Oct 30, 2026
530d777
chore: align the shared view-identity docs with N1
easonLiangWorldedtech Oct 30, 2026
dbc10fe
Merge remote-tracking branch 'upstream/main' into HEAD
easonLiangWorldedtech Oct 30, 2026
c8e5193
fix(webview): invalidate in-flight loads on reset and refresh sibling…
easonLiangWorldedtech Oct 30, 2026
623a7d4
chore: tighten the shared view-state docs
easonLiangWorldedtech Oct 30, 2026
fc78955
fix(lint): restore the ClineProvider suite suppression to its actual …
easonLiangWorldedtech Oct 30, 2026
bac0662
fix(webview): address the N5 review findings (batch 1)
easonLiangWorldedtech Oct 30, 2026
3c2f582
fix(webview): address the N5 review findings (batch 2)
easonLiangWorldedtech Oct 30, 2026
ad90355
Merge upstream/main into vps2/n5-ack-history
Nov 1, 2026
2760d03
chore(lint): prune suppressions left stale by the upstream merge
easonLiangWorldedtech Nov 1, 2026
f789fc8
style: format the suppressions file with prettier
easonLiangWorldedtech Nov 2, 2026
1eed57e
merge upstream/main (a4f774b17) into vps2/n5-ack-history
easonLiangWorldedtech Nov 2, 2026
c514fe4
Merge upstream/main (3ee042d91) into vps2/n5-ack-history
easonLiangWorldedtech Nov 3, 2026
9819b5f
fix(webview): address #2237 review findings on per-view restore
easonLiangWorldedtech Nov 3, 2026
4841456
Merge upstream/main (e943530c8) into vps2/n5-ack-history
easonLiangWorldedtech Nov 5, 2026
08b633d
chore(config): drop the recorded-value state cache now that failed wr…
easonLiangWorldedtech Nov 5, 2026
bf7a9e9
test(webview): re-adopt main's ten ClineProvider spec cases onto the …
easonLiangWorldedtech Nov 5, 2026
45795c8
test(webview): drop the three duplicated top-level cases, name the se…
easonLiangWorldedtech Nov 5, 2026
723c363
test(webview): pin zoo-gateway seeding to every-profile semantics
easonLiangWorldedtech Nov 5, 2026
49c626d
fix(webview): refresh pinned siblings and their tasks on a non-activa…
easonLiangWorldedtech Nov 5, 2026
27a2836
fix(webview): exempt keys ContextProxy does not count from the rollba…
easonLiangWorldedtech Nov 5, 2026
e398b67
test(webview): make the provider-filter assertion bite in both seedin…
easonLiangWorldedtech Nov 5, 2026
e9ac263
fix(config): refuse secret writes during a reset and clear the guard …
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
taskId?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
50 changes: 41 additions & 9 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,22 @@ 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 {
private readonly originalContext: vscode.ExtensionContext

private stateCache: GlobalState
private stateWriteGenerations = new Map<GlobalStateKey, number>()

private secretCache: SecretState
private _isInitialized = false
// Raised for the whole duration of a reset and cleared in a finally, so a reset that fails
// mid-way cannot leave the store refusing every later secret write; see resetAllState.
private resetting = false

constructor(context: vscode.ExtensionContext) {
this.originalContext = context
Expand Down Expand Up @@ -413,6 +421,8 @@ export class ContextProxy {
// writes that are in flight. An update that throws at once is turned into a rejection, so it is handled the same.
const owner = {}
this.stateWriteOwner.set(key, owner)
const generation = (this.stateWriteGenerations.get(key) ?? 0) + 1
this.stateWriteGenerations.set(key, generation)
this.stateCache[key] = value
const release = () => {
if (this.stateWriteOwner.get(key) === owner) this.stateWriteOwner.delete(key)
Expand All @@ -429,6 +439,11 @@ export class ContextProxy {
})
}

/** Current per-key write generation, used by callers whose rollback must not clobber a newer write. */
public getStateWriteGeneration<K extends GlobalStateKey>(key: K): number {
return this.stateWriteGenerations.get(key) ?? 0
}

private getAllGlobalState(): GlobalState {
return Object.fromEntries(GLOBAL_STATE_KEYS.map((key) => [key, this.getGlobalState(key)]))
}
Expand All @@ -443,6 +458,13 @@ export class ContextProxy {
}

storeSecret(key: SecretStateKey, value?: string) {
// 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.
if (this.resetting) {
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 Down Expand Up @@ -716,17 +738,27 @@ export class ContextProxy {
* @returns A promise that resolves when all reset operations are complete
*/
public async resetAllState() {
// Clear in-memory caches
this.stateCache = {}
this.secretCache = {}
this.resetting = true
try {
// Clear in-memory caches
this.stateCache = {}
this.secretCache = {}
// A write that started before this reset must not restore its pre-reset value when it
// rejects afterwards, so advance every generation the pending writes still hold.
for (const key of this.stateWriteGenerations.keys()) {
this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1)
}

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 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()
await this.initialize()
} finally {
this.resetting = false
}
}

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