Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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 @@ -111,6 +111,7 @@ export interface ExtensionMessage {
| "originalContent"
| "rooHistoryImportProgress"
| "themeFixtureProbeRequest"
| "viewStateClaim"
text?: string
/** For fileContent: { path, content, error? } */
fileContent?: { path: string; content: string | null; error?: string }
Expand Down Expand Up @@ -169,6 +170,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
promptText?: string
results?:
Expand Down Expand Up @@ -670,6 +678,9 @@ export interface WebviewMessage {
| "openRulesDirectory"
| "themeFixtureProbeResponse"
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
39 changes: 37 additions & 2 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,20 @@ 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>()

// The value storage last accepted per key: a failed write must roll the cache back to
// what is actually persisted, not to another pending write's unpersisted value.
private statePersistedCache = new Map<GlobalStateKey, unknown>()
private secretCache: SecretState
private _isInitialized = false

Expand Down Expand Up @@ -360,13 +368,35 @@ export class ContextProxy {
return value !== undefined ? value : defaultValue
}

updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
async updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
}

const previousCachedValue = this.stateCache[key]
const generation = (this.stateWriteGenerations.get(key) ?? 0) + 1
this.stateWriteGenerations.set(key, generation)
this.stateCache[key] = value
return this.originalContext.globalState.update(key, value)

try {
const result = await this.originalContext.globalState.update(key, value)
this.statePersistedCache.set(key, value)
return result
} catch (error) {
// A newer write for the same key may already own the cache: only undo this write
// while it is still the newest one, and only to the last value storage accepted.
if (this.stateWriteGenerations.get(key) === generation) {
this.stateCache[key] = this.statePersistedCache.has(key)
? (this.statePersistedCache.get(key) as GlobalState[K])
: previousCachedValue
}
throw error
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/** 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 {
Expand Down Expand Up @@ -590,6 +620,11 @@ export class ContextProxy {
// 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)),
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
85 changes: 85 additions & 0 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,75 @@ describe("ContextProxy", () => {
expect(result).toBe("deepseek")
})

it("should not let an older rejected write clobber a newer cached value", async () => {
let rejectFirst: (error: Error) => void = () => {}
const firstWriteResult = new Promise<void>((_, reject) => {
rejectFirst = reject
})

mockGlobalState.update.mockReset()
mockGlobalState.update.mockReturnValueOnce(firstWriteResult)
mockGlobalState.update.mockResolvedValueOnce(undefined)

const firstWrite = proxy.updateGlobalState("apiProvider", "anthropic")
await proxy.updateGlobalState("apiProvider", "openai")

// The older write rejects after a newer value already reached the cache: rolling
// the cache back to the pre-first-write value would diverge from storage.
rejectFirst(new Error("disk full"))
await expect(firstWrite).rejects.toThrow("disk full")

expect(proxy.getGlobalState("apiProvider")).toBe("openai")
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
})

it("should not restore a pre-reset value when a pending write rejects after reset", async () => {
let rejectPending: (error: Error) => void = () => {}
mockGlobalState.update.mockReset()
mockGlobalState.update.mockReturnValueOnce(
new Promise<void>((_, reject) => {
rejectPending = reject
}),
)

const pending = proxy.updateGlobalState("apiProvider", "anthropic")
await proxy.resetAllState()
rejectPending(new Error("disk full"))

await expect(pending).rejects.toThrow("disk full")
// The reset cleared the cache: restoring the pre-reset value would resurrect state
// that storage no longer holds.
expect(proxy.getGlobalState("apiProvider")).toBeUndefined()
})

it("should restore the last persisted value when two overlapping writes both reject", async () => {
await proxy.updateGlobalState("apiProvider", "anthropic")
mockGlobalState.update.mockReset()
let rejectFirst: (error: Error) => void = () => {}
let rejectSecond: (error: Error) => void = () => {}
mockGlobalState.update
.mockReturnValueOnce(
new Promise<void>((_, reject) => {
rejectFirst = reject
}),
)
.mockReturnValueOnce(
new Promise<void>((_, reject) => {
rejectSecond = reject
}),
)

const firstWrite = proxy.updateGlobalState("apiProvider", "openrouter")
const secondWrite = proxy.updateGlobalState("apiProvider", "deepseek")
rejectSecond(new Error("second failed"))
await expect(secondWrite).rejects.toThrow("second failed")
rejectFirst(new Error("first failed"))
await expect(firstWrite).rejects.toThrow("first failed")

// Neither attempt reached storage: the cache must hold the last persisted value,
// not the first attempt's unpersisted value.
expect(proxy.getGlobalState("apiProvider")).toBe("anthropic")
})

it("should bypass cache for pass-through state keys", async () => {
// Setup mock return value
mockGlobalState.get.mockReturnValue("pass-through-value")
Expand Down Expand Up @@ -740,4 +809,20 @@ Output only the summary of the conversation so far, without any additional comme
expect(customSupportPromptsUpdateCalls.length).toBe(0)
})
})

describe("export", () => {
it("should exclude viewStates from the exported settings", async () => {
await proxy.setValue("viewStates", {
"stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 },
})
await proxy.setValue("customInstructions", "global instructions")

const exported = await proxy.export()

// Per-view selection state is machine-local and must never transfer
// between settings, while ordinary global settings keep round-tripping.
expect(exported).not.toHaveProperty("viewStates")
expect(exported?.customInstructions).toBe("global instructions")
})
})
})
7 changes: 6 additions & 1 deletion src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { clearAllMocks } from "../../../test-utils/reset"
import { makeExtensionContext } from "../../../test-utils/vscode"

import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager"
import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager"

// `export()` builds an API handler per profile to read model capabilities. Mock
// buildApiHandler with the real @roo-code/types model definitions so the token-field
Expand Down Expand Up @@ -973,6 +973,11 @@ describe("ProviderSettingsManager", () => {
}),
)

// The typed not-found signal is the contract callers branch on: a profile
// name containing "not found" must not be matchable via message text.
await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf(
ProviderSettingsNotFoundError,
)
await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow(
"Config 'nonexistent' not found",
)
Expand Down
Loading
Loading