Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
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
22 changes: 15 additions & 7 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useAPI } from "@/browser/contexts/API";
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import {
clearPendingWorkspaceAiSettings,
markPendingWorkspaceAiSettings,
Expand Down Expand Up @@ -456,11 +457,18 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

const canonicalModel = migrateGatewayModel(model);
ensureModelInSettings(canonicalModel); // Ensure model exists in Settings
updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific

// Notify parent of model change (for context switch warning)
// Called before early returns so warning works even offline or with custom agents
onModelChange?.(canonicalModel);
if (onModelChange) {
// Notify parent of model change (for context switch warning + persisted model metadata).
// Called before early returns so warnings work even offline or with custom agents.
onModelChange(canonicalModel);
} else {
const scopeId =
variant === "creation" ? getProjectScopeId(creationProjectPath) : workspaceId;
if (scopeId) {
setWorkspaceModelWithOrigin(scopeId, canonicalModel, "user");
}
}

if (variant !== "workspace" || !workspaceId) {
return;
Expand Down Expand Up @@ -513,12 +521,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
[
api,
agentId,
storageKeys.modelKey,
creationProjectPath,
ensureModelInSettings,
onModelChange,
thinkingLevel,
variant,
workspaceId,
onModelChange,
]
);

Expand Down Expand Up @@ -762,7 +770,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off";

if (existingModel !== resolvedModel) {
updatePersistedState(modelKey, resolvedModel);
setWorkspaceModelWithOrigin(scopeId, resolvedModel, "agent");
}

if (existingThinking !== resolvedThinking) {
Expand Down
3 changes: 2 additions & 1 deletion src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { RuntimeChoice } from "@/browser/utils/runtimeUi";
import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime";
import type { ThinkingLevel } from "@/common/types/thinking";
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import {
Expand Down Expand Up @@ -70,7 +71,7 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
// This ensures the model used for creation is persisted for future resumes
const projectModel = readPersistedState<string | null>(getModelKey(projectScopeId), null);
if (projectModel) {
updatePersistedState(getModelKey(workspaceId), projectModel);
setWorkspaceModelWithOrigin(workspaceId, projectModel, "sync");
}

const projectAgentId = readPersistedState<string | null>(getAgentIdKey(projectScopeId), null);
Expand Down
5 changes: 4 additions & 1 deletion src/browser/components/ContextSwitchWarning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export const ContextSwitchWarning: React.FC<Props> = (props) => {
: null;

return (
<div className="bg-plan-mode/10 border-plan-mode/30 mx-4 my-2 rounded-md border px-4 py-3">
<div
data-testid="context-switch-warning"
className="bg-plan-mode/10 border-plan-mode/30 mx-4 my-2 rounded-md border px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-plan-mode mb-1 flex items-center gap-2 text-[13px] font-medium">
Expand Down
3 changes: 2 additions & 1 deletion src/browser/components/WorkspaceModeAISync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
AGENT_AI_DEFAULTS_KEY,
} from "@/common/constants/storage";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking";
import type { AgentAiDefaults } from "@/common/types/agentAiDefaults";

Expand Down Expand Up @@ -85,7 +86,7 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off";

if (existingModel !== resolvedModel) {
updatePersistedState(modelKey, resolvedModel);
setWorkspaceModelWithOrigin(workspaceId, resolvedModel, "agent");
}

if (existingThinking !== resolvedThinking) {
Expand Down
110 changes: 110 additions & 0 deletions src/browser/contexts/PolicyContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import React from "react";
import { APIProvider, type APIClient } from "@/browser/contexts/API";
import type { PolicyGetResponse } from "@/common/orpc/types";
import { PolicyProvider, usePolicy } from "./PolicyContext";

async function* emptyStream() {
// no-op
}

const buildWrapper = (client: APIClient): React.FC<{ children: React.ReactNode }> => {
const Wrapper = (props: { children: React.ReactNode }) =>
React.createElement(
APIProvider,
{ client } as React.ComponentProps<typeof APIProvider>,
React.createElement(PolicyProvider, null, props.children)
);
Wrapper.displayName = "PolicyContextTestWrapper";
return Wrapper;
};

const buildBlockedResponse = (reason: string): PolicyGetResponse => ({
status: { state: "blocked", reason },
policy: null,
});

const buildEnforcedResponse = (): PolicyGetResponse => ({
status: { state: "enforced" },
policy: {
policyFormatVersion: "0.1",
providerAccess: null,
mcp: { allowUserDefined: { stdio: true, remote: true } },
runtimes: null,
},
});

describe("PolicyContext", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.localStorage = globalThis.window.localStorage;
globalThis.localStorage.clear();
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
globalThis.localStorage = undefined as unknown as Storage;
});

test("updates when blocked reason changes", async () => {
const responses = [buildBlockedResponse("Reason A"), buildBlockedResponse("Reason B")];
let callCount = 0;

const get = mock(() => {
const response = responses[Math.min(callCount, responses.length - 1)];
callCount += 1;
return Promise.resolve(response);
});

const client = {
policy: {
get,
onChanged: () => Promise.resolve(emptyStream()),
},
} as unknown as APIClient;

const { result } = renderHook(() => usePolicy(), {
wrapper: buildWrapper(client),
});

await waitFor(() => expect(result.current.status.reason).toBe("Reason A"));

await act(async () => {
await result.current.refresh();
});

await waitFor(() => expect(result.current.status.reason).toBe("Reason B"));
});

test("keeps identical policy responses stable", async () => {
const get = mock(() => Promise.resolve(buildEnforcedResponse()));

const client = {
policy: {
get,
onChanged: () => Promise.resolve(emptyStream()),
},
} as unknown as APIClient;

const { result } = renderHook(() => usePolicy(), {
wrapper: buildWrapper(client),
});

await waitFor(() => expect(result.current.policy).not.toBeNull());

const firstPolicy = result.current.policy;
const firstStatus = result.current.status;

await act(async () => {
await result.current.refresh();
});

expect(result.current.policy).toBe(firstPolicy);
expect(result.current.status).toBe(firstStatus);
});
});
17 changes: 15 additions & 2 deletions src/browser/contexts/PolicyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ interface PolicyContextValue {

const PolicyContext = createContext<PolicyContextValue | null>(null);

// User request: keep churn guard while still surfacing updated policy reasons.
const getPolicySignature = (response: PolicyGetResponse): string =>
JSON.stringify({ status: response.status, policy: response.policy });

export function PolicyProvider(props: { children: React.ReactNode }) {
const apiState = useAPI();
const api = apiState.api;
Expand All @@ -26,9 +30,18 @@ export function PolicyProvider(props: { children: React.ReactNode }) {

try {
const next = await api.policy.get();
setResponse(next);
// User request: avoid churn from identical payloads while letting reason updates through.
setResponse((prev) => {
if (!prev) {
return next;
}
if (getPolicySignature(prev) === getPolicySignature(next)) {
return prev;
}
return next;
});
} catch {
setResponse(null);
setResponse((prev) => (prev ? null : prev));
} finally {
setLoading(false);
}
Expand Down
3 changes: 2 additions & 1 deletion src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
WORKSPACE_DRAFTS_BY_PROJECT_KEY,
} from "@/common/constants/storage";
import { useAPI } from "@/browser/contexts/API";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import {
readPersistedState,
updatePersistedState,
Expand Down Expand Up @@ -124,7 +125,7 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat
const modelKey = getModelKey(workspaceId);
const existingModel = readPersistedState<string | undefined>(modelKey, undefined);
if (existingModel !== active.model) {
updatePersistedState(modelKey, active.model);
setWorkspaceModelWithOrigin(workspaceId, active.model, "sync");
}

const thinkingKey = getThinkingLevelKey(workspaceId);
Expand Down
Loading
Loading