Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
171 changes: 170 additions & 1 deletion src/renderer/components/thread/ThreadDraftView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
import {
Children,
isValidElement,
type ComponentProps,
type ReactElement,
type ReactNode,
} from "react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithI18n as render } from "@/renderer/testUtils/i18n";
Expand Down Expand Up @@ -52,6 +58,19 @@ const project: Project = {
createdAt: "2026-03-28T00:00:00.000Z",
};

const legacyCodexProject: Project = {
...project,
lastDraftConfig: {
agentKind: "codex",
model: "gpt-5.4",
effort: "high",
mode: "agent",
approvalPolicy: "on-request",
approvalsReviewer: "auto_review",
sandboxMode: "workspace-write",
},
};

const remoteProject: Project = {
...project,
id: "remote-project",
Expand Down Expand Up @@ -125,6 +144,22 @@ const dualModeCodexStatus: AgentStatus = {
},
};

const contextualCodexStatus: AgentStatus = {
...dualModeCodexStatus,
capabilities: {
...dualModeCodexStatus.capabilities,
contextSizes: [
{ id: "272k", label: "272k" },
{ id: "400k", label: "400k" },
{ id: "1m", label: "1M" },
],
modelContextSizes: {
"gpt-5.4": ["272k", "400k", "1m"],
},
defaultContextSize: "272k",
},
};

const geminiStatus: AgentStatus = {
kind: "gemini",
label: "Gemini",
Expand Down Expand Up @@ -293,6 +328,23 @@ const acpGenericStatus: AgentStatus = {
},
};

function StoreBackedThreadDraftView(props: {
onStart: ComponentProps<typeof ThreadDraftView>["onStart"];
}) {
const storedProject = useAppStore((state) =>
state.projects.find((candidate) => candidate.id === project.id),
);
if (!storedProject) return null;
return (
<ThreadDraftView
project={storedProject}
agentStatuses={[contextualCodexStatus]}
{...(storedProject.lastDraftConfig ? { lastDraftConfig: storedProject.lastDraftConfig } : {})}
onStart={props.onStart}
/>
);
}

function classNameIncludes(element: HTMLElement, value: string): boolean {
return typeof element.className === "string" && element.className.includes(value);
}
Expand Down Expand Up @@ -1098,6 +1150,123 @@ describe("ThreadDraftView", () => {
});
});

it("inherits the saved Codex context window when the project draft predates it", async () => {
const onStart = vi.fn<(input: unknown) => void>();
useSharedSettings.setState({ sharedSettingsHydrated: false, providerConfigs: {} });
useAppStore.setState({ projects: [legacyCodexProject] });

render(<StoreBackedThreadDraftView onStart={onStart} />);

await waitFor(() => {
const props = composerSpy.mock.lastCall?.[0] as {
controls: Array<{ kind?: string; contextValue?: string }>;
};
const effortContext = props.controls.find((control) => control.kind === "effort-context");
expect(effortContext?.contextValue).toBe("272k");
});

const initialProps = composerSpy.mock.lastCall?.[0] as {
controls: Array<{ kind?: string; onEffortChange?: (value: string) => void }>;
};
const initialEffortContext = initialProps.controls.find(
(control) => control.kind === "effort-context",
);
act(() => initialEffortContext?.onEffortChange?.("xhigh"));

act(() => {
useSharedSettings.setState({
providerConfigs: {
codex: {
model: "gpt-5.4",
effort: "medium",
contextSize: "400k",
mode: "agent",
approvalPolicy: "on-request",
approvalsReviewer: "auto_review",
sandboxMode: "workspace-write",
},
},
sharedSettingsHydrated: true,
});
});

await waitFor(() => {
const props = composerSpy.mock.lastCall?.[0] as {
controls: Array<{ kind?: string; contextValue?: string; effortValue?: string }>;
};
const effortContext = props.controls.find((control) => control.kind === "effort-context");
expect(effortContext?.contextValue).toBe("400k");
expect(effortContext?.effortValue).toBe("xhigh");
});

fireEvent.click(screen.getByText("set-prompt"));
fireEvent.click(screen.getByText("submit"));

expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ contextSize: "400k", effort: "xhigh" }),
}),
);
});

it("keeps an explicit Codex context choice made before settings hydrate", async () => {
const onStart = vi.fn<(input: unknown) => void>();
useSharedSettings.setState({ sharedSettingsHydrated: false, providerConfigs: {} });
useAppStore.setState({ projects: [legacyCodexProject] });

render(<StoreBackedThreadDraftView onStart={onStart} />);

await waitFor(() => {
const props = composerSpy.mock.lastCall?.[0] as {
controls: Array<{ kind?: string; contextValue?: string }>;
};
const effortContext = props.controls.find((control) => control.kind === "effort-context");
expect(effortContext?.contextValue).toBe("272k");
});

const initialProps = composerSpy.mock.lastCall?.[0] as {
controls: Array<{ kind?: string; onContextChange?: (value: string) => void }>;
};
const initialEffortContext = initialProps.controls.find(
(control) => control.kind === "effort-context",
);
act(() => initialEffortContext?.onContextChange?.("1m"));

act(() => {
useSharedSettings.setState({
providerConfigs: {
codex: {
model: "gpt-5.4",
effort: "medium",
contextSize: "400k",
mode: "agent",
approvalPolicy: "on-request",
approvalsReviewer: "auto_review",
sandboxMode: "workspace-write",
},
},
sharedSettingsHydrated: true,
});
});

await waitFor(() => {
const props = composerSpy.mock.lastCall?.[0] as {
controls: Array<{ kind?: string; contextValue?: string }>;
};
const effortContext = props.controls.find((control) => control.kind === "effort-context");
expect(effortContext?.contextValue).toBe("1m");
});

fireEvent.click(screen.getByText("set-prompt"));
fireEvent.click(screen.getByText("submit"));

expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ contextSize: "1m" }),
}),
);
});

it("submits an explicit Fast-off selection in the launch config", async () => {
const onStart = vi.fn<(input: unknown) => void>();

Expand Down
87 changes: 72 additions & 15 deletions src/renderer/components/thread/ThreadDraftView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,17 @@ export function ThreadDraftView(props: {
installedAgents.find((status) => status.kind === effectiveAgentKind) ?? installedAgents[0];
const [model, setModel] = useState("");
const [effort, setEffort] = useState("");
const [contextSize, setContextSize] = useState<string | undefined>(undefined);
const [contextSize, setContextSize] = useState<string | undefined>(() => {
if (
lastDraftConfig &&
lastDraftConfig.agentKind === preferredAgentKind &&
lastDraftConfig.contextSize
) {
return lastDraftConfig.contextSize;
}
if (!preferredAgentKind || isHomeScope) return undefined;
return useSharedSettings.getState().providerConfigs[preferredAgentKind]?.contextSize;
});
const [fast, setFast] = useState(false);
const [thinking, setThinking] = useState(false);
const [mode, setMode] = useState<"agent" | "plan" | "autopilot">("agent");
Expand Down Expand Up @@ -315,7 +325,9 @@ export function ThreadDraftView(props: {
const setProviderConfig = useSharedSettings((s) => s.setProviderConfig);
const effectiveAgentKindRef = useRef(effectiveAgentKind);
const providerConfigsRef = useRef<Record<string, ProviderDraftConfig>>({});
const initialLastDraftConfigRef = useRef(lastDraftConfig);
const hasLocalConfigEditRef = useRef(false);
const hasLocalContextEditRef = useRef(false);
effectiveAgentKindRef.current = effectiveAgentKind;
// Spread is required: the effects below mutate `providerConfigsRef.current[kind]`
// in place to keep effort/model selections in sync mid-render. Assigning the
Expand Down Expand Up @@ -505,22 +517,63 @@ export function ThreadDraftView(props: {
]);

useEffect(() => {
if (isHomeScope || !sharedSettingsHydrated || hasLocalConfigEditRef.current) {
if (isHomeScope || !sharedSettingsHydrated) {
return;
}
if (!selectedAgentForConfig || !effectiveAgentKind) {
return;
}
if (lastDraftConfig?.agentKind === effectiveAgentKind && lastDraftConfig.model.trim()) {
const initialLastDraftConfig = initialLastDraftConfigRef.current;
const hasInitialProjectDraft =
initialLastDraftConfig?.agentKind === effectiveAgentKind &&
Boolean(initialLastDraftConfig.model.trim());
const hasInitialContext = hasInitialProjectDraft && Boolean(initialLastDraftConfig.contextSize);
const shouldInheritContext = !hasInitialContext && !hasLocalContextEditRef.current;
if (hasLocalConfigEditRef.current && !shouldInheritContext) {
return;
}
if (hasInitialContext) {
return;
}

const saved = useSharedSettings.getState().providerConfigs[effectiveAgentKind];
if (!saved) {
const providerConfigs = useSharedSettings.getState().providerConfigs;
const providerConfig = providerConfigs[effectiveAgentKind];
if (!providerConfig) {
return;
}
providerConfigsRef.current = { ...providerConfigs };

if (hasLocalConfigEditRef.current) {
const nextContext = resolveContextSizeValue(
selectedAgentForConfig,
model,
providerConfig.contextSize,
);
if (nextContext === contextSize) {
return;
}
setContextSize(nextContext);
updateProjectDraftConfig(project.id, {
agentKind: effectiveAgentKind,
model,
effort,
...(nextContext ? { contextSize: nextContext } : {}),
...(supportsUsableFastMode(selectedAgentForConfig.capabilities, model) ? { fast } : {}),
...(selectedAgentForConfig.capabilities.thinkingModels?.includes(model)
? { thinking }
: {}),
mode,
approvalPolicy,
approvalsReviewer,
sandboxMode,
worktreeMode: effectiveWorktreeMode,
});
return;
}
providerConfigsRef.current = { ...useSharedSettings.getState().providerConfigs };

const saved = hasInitialProjectDraft
? resolveSavedProviderDraftConfig(effectiveAgentKind, initialLastDraftConfig, providerConfigs)
: providerConfig;
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, saved);
const nextModel = resolved.model;
const nextEffort = resolved.effort ?? "";
Expand Down Expand Up @@ -558,15 +611,16 @@ export function ThreadDraftView(props: {
lastAppliedAgentKindRef.current = effectiveAgentKind;

if (
saved.model !== nextModel ||
saved.effort !== nextEffort ||
saved.contextSize !== nextContext ||
saved.fast !== nextFast ||
saved.thinking !== nextThinking ||
saved.mode !== nextMode ||
saved.approvalPolicy !== nextApproval ||
saved.approvalsReviewer !== nextReviewer ||
saved.sandboxMode !== nextSandbox
!hasInitialProjectDraft &&
(providerConfig.model !== nextModel ||
providerConfig.effort !== nextEffort ||
providerConfig.contextSize !== nextContext ||
providerConfig.fast !== nextFast ||
providerConfig.thinking !== nextThinking ||
providerConfig.mode !== nextMode ||
providerConfig.approvalPolicy !== nextApproval ||
providerConfig.approvalsReviewer !== nextReviewer ||
providerConfig.sandboxMode !== nextSandbox)
) {
providerConfigsRef.current[effectiveAgentKind] = resolved;
setProviderConfig(effectiveAgentKind, resolved);
Expand Down Expand Up @@ -669,6 +723,9 @@ export function ThreadDraftView(props: {
}
if (!selectedAgentForConfig) return;
hasLocalConfigEditRef.current = true;
if ("contextSize" in patch) {
hasLocalContextEditRef.current = true;
}
const resolved = resolveProviderDraftConfig(selectedAgentForConfig, {
model: patch.model ?? model,
effort: patch.effort ?? effort,
Expand Down
35 changes: 35 additions & 0 deletions src/renderer/components/thread/threadDraftViewHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AgentCapability, AgentStatus } from "@/shared/contracts";
import {
resolveFastValue,
resolveProviderDraftConfig,
resolveSavedProviderDraftConfig,
resolveThinkingValue,
} from "./threadDraftViewHelpers";

Expand Down Expand Up @@ -99,3 +100,37 @@ describe("resolveThinkingValue", () => {
expect(resolveThinkingValue(agentWith({ thinkingModels: ["plain"] }), "plain")).toBe(false);
});
});

describe("resolveSavedProviderDraftConfig", () => {
it("fills an omitted context window from the app-wide provider preset", () => {
const resolved = resolveSavedProviderDraftConfig(
"codex",
{ agentKind: "codex", model: "gpt-5.6-sol", effort: "high" },
{
codex: {
model: "gpt-5.6-sol",
contextSize: "400k",
effort: "medium",
fast: false,
},
},
);

expect(resolved).toMatchObject({
model: "gpt-5.6-sol",
effort: "high",
contextSize: "400k",
});
expect(resolved?.fast).toBeUndefined();
});

it("keeps an explicit last-draft context size over the provider preset", () => {
expect(
resolveSavedProviderDraftConfig(
"codex",
{ agentKind: "codex", model: "gpt-5.6-sol", contextSize: "1m" },
{ codex: { model: "gpt-5.6-sol", contextSize: "400k" } },
),
).toMatchObject({ contextSize: "1m" });
});
});
Loading