fix(dashboard): lazy-init useMobileKeyboard so remount has no stale render

When ChatView remounted (e.g. tab switch with keyboard still up), the
hook started with keyboardOpen=false and corrected itself only after
the effect ran. That single stale-state render briefly unhid the
executor status bar, which appeared as a blank pane covering half the
input box before the next state update settled it.

useState initializers now call getKeyboardMetrics() lazily on first
render so the very first paint already reflects the live keyboard
state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 21:38:50 -07:00
parent 9a35f4f006
commit 3f5d01f4d4
6 changed files with 186 additions and 60 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix an engine compatibility bug where reviewer/triage/executor runs could fail when a provider extension rejected both `thinking` and `reasoning_effort` together. Fusion now retries without the explicit thinking-level override for that conflict instead of marking the run unavailable.

View File

@@ -38,7 +38,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
| `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. |
| `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures. | | `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures. |
| `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). | | `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). |
| `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. | | `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high"` | `undefined` | Default reasoning effort for AI sessions. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. |
| `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. | | `ntfyEnabled` | `boolean` | `false` | Enable ntfy push notifications. |
| `ntfyTopic` | `string` | `undefined` | ntfy topic name. | | `ntfyTopic` | `string` | `undefined` | ntfy topic name. |
| `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. | | `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. |

View File

@@ -110,10 +110,23 @@ interface UseMobileKeyboardOptions {
export function useMobileKeyboard( export function useMobileKeyboard(
{ enabled = true }: UseMobileKeyboardOptions = {}, { enabled = true }: UseMobileKeyboardOptions = {},
): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } { ): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } {
const [keyboardOverlap, setKeyboardOverlap] = useState(0); // Lazy initial values: read the actual visualViewport on first render
const [viewportHeight, setViewportHeight] = useState<number | null>(null); // so a remount (e.g. switching tabs back with the keyboard up) doesn't
const [viewportOffsetTop, setViewportOffsetTop] = useState(0); // start with keyboardOpen=false. That stale-state render briefly hid
const [keyboardOpen, setKeyboardOpen] = useState(false); // the .chat-thread compensation and unhid the executor status bar,
// which then reappeared as a blank pane covering half the composer
// before settling.
const initialMetrics = (): KeyboardMetrics => {
if (!enabled || typeof window === "undefined" || !isMobileDevice()) {
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
}
return getKeyboardMetrics();
};
const [initial] = useState(initialMetrics);
const [keyboardOverlap, setKeyboardOverlap] = useState(initial.overlap);
const [viewportHeight, setViewportHeight] = useState<number | null>(initial.vvHeight);
const [viewportOffsetTop, setViewportOffsetTop] = useState(initial.vvOffsetTop);
const [keyboardOpen, setKeyboardOpen] = useState(initial.open);
useEffect(() => { useEffect(() => {
if (!enabled || !isMobileDevice()) { if (!enabled || !isMobileDevice()) {

View File

@@ -993,6 +993,36 @@ describe("createFnAgent", () => {
})); }));
}); });
it("continues session creation when setting thinking level hits reasoning conflict", async () => {
const { piLog } = await import("../logger.js");
const warnSpy = vi.spyOn(piLog, "warn").mockImplementation(() => {});
const setThinkingLevel = vi.fn(() => {
throw new Error("400 cannot specify both 'thinking' and 'reasoning_effort'");
});
createAgentSessionMock.mockResolvedValueOnce({
session: {
prompt: vi.fn(),
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel,
},
});
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultThinkingLevel: "high",
})).resolves.toBeTruthy();
expect(setThinkingLevel).toHaveBeenCalledWith("high");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Disabling explicit thinking level"));
warnSpy.mockRestore();
});
describe("skill selection", () => { describe("skill selection", () => {
beforeEach(() => { beforeEach(() => {
// Reset modules to ensure fresh imports for each test // Reset modules to ensure fresh imports for each test

View File

@@ -662,11 +662,53 @@ describe("session failure diagnostics", () => {
await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined(); await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith( expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to dispose session during model fallback swap: dispose failed"), expect.stringContaining("Failed to dispose session during swap: dispose failed"),
); );
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
it("retries prompt on thinking/reasoning conflict without switching fallback models", async () => {
const createAgentSessionMock = vi.mocked(createAgentSession);
const firstSession = {
model: { provider: "test", id: "primary-model" },
prompt: vi.fn().mockRejectedValue(new Error("400 cannot specify both 'thinking' and 'reasoning_effort'")),
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel: vi.fn(),
sessionFile: undefined,
} as unknown as AgentSession;
const retrySession = {
model: { provider: "test", id: "primary-model" },
prompt: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn(),
dispose: vi.fn(),
setThinkingLevel: vi.fn(),
sessionFile: undefined,
} as unknown as AgentSession;
createAgentSessionMock.mockReset();
createAgentSessionMock
.mockResolvedValueOnce({ session: firstSession } as any)
.mockResolvedValueOnce({ session: retrySession } as any);
const { session } = await createFnAgent({
cwd: "/test/project",
systemPrompt: "Test thinking compatibility",
defaultProvider: "test",
defaultModelId: "primary-model",
fallbackProvider: "test",
fallbackModelId: "fallback-model",
defaultThinkingLevel: "high",
});
await expect((session as any).promptWithFallback("Run review")).resolves.toBeUndefined();
expect(createAgentSessionMock).toHaveBeenCalledTimes(2);
expect((retrySession.setThinkingLevel as any).mock.calls.length).toBe(0);
});
}); });
describe("piLog structured diagnostics", () => { describe("piLog structured diagnostics", () => {

View File

@@ -130,6 +130,10 @@ function clearSessionStateError(session: AgentSession): void {
} }
} }
function isThinkingReasoningConflictError(message: string): boolean {
return /cannot specify both\s+['"]?thinking['"]?\s+and\s+['"]?reasoning_effort['"]?/i.test(message);
}
async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> { async function promptSessionAndCheck(session: AgentSession, prompt: string, options?: unknown): Promise<void> {
clearSessionStateError(session); clearSessionStateError(session);
if (options === undefined) { if (options === undefined) {
@@ -1283,78 +1287,37 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
piLog.log("Fallback session created successfully"); piLog.log("Fallback session created successfully");
} }
const { session } = sessionResult; let activeSession = sessionResult.session;
installToolResultContentGuard(session as AgentToolHookSession); installToolResultContentGuard(activeSession as AgentToolHookSession);
installMessageContentGuard(session as AgentToolHookSession, sessionManager as unknown as SessionManagerLike); installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
(session as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; (activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
const promptableSession = session as PromptableSession; const promptableSession = activeSession as PromptableSession;
promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => { let thinkingCompatibilityDisabled = false;
try { const applyThinkingLevelIfSupported = (targetSession: AgentSession, sourceModel: string): void => {
await promptSessionAndCheck(session, prompt, promptOptions); if (!options.defaultThinkingLevel || thinkingCompatibilityDisabled) {
return;
} catch (err: any) {
const errorMessage = err?.message || "";
if (isContextLimitError(errorMessage)) {
// Context limit error — attempt auto-compaction and retry once
const promptMemoryRetry = await retryWithCompactedPromptMemory(session, prompt, promptOptions);
if (promptMemoryRetry.recovered) {
return; return;
} }
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(session);
const compactResult = await compactSessionContext(session);
if (compactResult) {
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try { try {
await promptSessionAndCheck(session, prompt, promptOptions); (targetSession as PromptableSession).setThinkingLevel(options.defaultThinkingLevel as any);
return;
} catch (retryErr: any) {
const retryErrorMessage = retryErr?.message || "";
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
// Throw original error to preserve original context
throw err;
}
} else {
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
throw err;
}
}
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
throw err;
}
usingFallback = true;
try {
session.dispose();
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
piLog.warn(`Failed to dispose session during model fallback swap: ${msg}`); if (!isThinkingReasoningConflictError(message)) {
throw err;
} }
thinkingCompatibilityDisabled = true;
piLog.warn(`Disabling explicit thinking level for model ${sourceModel}: ${message}`);
}
};
const fallbackSessionResult = await createSessionWithModel(fallbackModel); const wireFallbackHooks = (targetSession: PromptableSession): void => {
await emitFallbackUsed("prompt-time"); installToolResultContentGuard(targetSession as unknown as AgentToolHookSession);
const fallbackSession = fallbackSessionResult.session as PromptableSession;
installToolResultContentGuard(fallbackSession as unknown as AgentToolHookSession);
installMessageContentGuard( installMessageContentGuard(
fallbackSession as unknown as AgentToolHookSession, targetSession as unknown as AgentToolHookSession,
sessionManager as unknown as SessionManagerLike, sessionManager as unknown as SessionManagerLike,
); );
(fallbackSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true; (targetSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
targetSession.subscribe((event) => {
if (options.defaultThinkingLevel) {
fallbackSession.setThinkingLevel(options.defaultThinkingLevel as any);
}
fallbackSession.subscribe((event) => {
if (event.type === "message_update") { if (event.type === "message_update") {
const msgEvent = event.assistantMessageEvent; const msgEvent = event.assistantMessageEvent;
if (msgEvent.type === "text_delta") { if (msgEvent.type === "text_delta") {
@@ -1370,10 +1333,82 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
options.onToolEnd?.(event.toolName, event.isError, event.result); options.onToolEnd?.(event.toolName, event.isError, event.result);
} }
}); });
};
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(fallbackSession)); const swapPromptSession = async (modelToUse: typeof selectedModel): Promise<PromptableSession> => {
Object.assign(promptableSession, fallbackSession); if (!modelToUse) {
promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback; throw new Error("Cannot swap session without a resolved model");
}
try {
activeSession.dispose();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
piLog.warn(`Failed to dispose session during swap: ${msg}`);
}
const next = (await createSessionWithModel(modelToUse)).session as PromptableSession;
wireFallbackHooks(next);
applyThinkingLevelIfSupported(next, `${modelToUse.provider}/${modelToUse.id}`);
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(next));
Object.assign(promptableSession, next);
promptableSession.promptWithFallback = next.promptWithFallback ?? promptableSession.promptWithFallback;
activeSession = next;
return next;
};
promptableSession.promptWithFallback = async (prompt: string, promptOptions?: unknown) => {
try {
await promptSessionAndCheck(activeSession, prompt, promptOptions);
return;
} catch (err: any) {
const errorMessage = err?.message || "";
if (isContextLimitError(errorMessage)) {
// Context limit error — attempt auto-compaction and retry once
const promptMemoryRetry = await retryWithCompactedPromptMemory(activeSession, prompt, promptOptions);
if (promptMemoryRetry.recovered) {
return;
}
if (promptMemoryRetry.error) {
const retryMessage = promptMemoryRetry.error instanceof Error ? promptMemoryRetry.error.message : String(promptMemoryRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptMemoryRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(activeSession);
const compactResult = await compactSessionContext(activeSession);
if (compactResult) {
piLog.log(`promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
await promptSessionAndCheck(activeSession, prompt, promptOptions);
return;
} catch (retryErr: any) {
const retryErrorMessage = retryErr?.message || "";
piLog.error(`promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
// Throw original error to preserve original context
throw err;
}
} else {
piLog.error("promptWithFallback: compaction unavailable — propagating original error");
throw err;
}
}
if (!usingFallback && options.defaultThinkingLevel && !thinkingCompatibilityDisabled && isThinkingReasoningConflictError(errorMessage)) {
thinkingCompatibilityDisabled = true;
piLog.warn(`Prompt failed with thinking/reasoning conflict; retrying without explicit thinking level: ${errorMessage}`);
const recoveredSession = await swapPromptSession(selectedModel);
await promptSessionAndCheck(recoveredSession, prompt, promptOptions);
return;
}
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
throw err;
}
usingFallback = true;
const fallbackSession = await swapPromptSession(fallbackModel);
await emitFallbackUsed("prompt-time");
// Retry with fallback model, also with auto-compaction support // Retry with fallback model, also with auto-compaction support
try { try {
@@ -1416,10 +1451,11 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
} }
}; };
// Apply thinking level if specified // Apply thinking level if specified (with compatibility fallback).
if (options.defaultThinkingLevel) { applyThinkingLevelIfSupported(
promptableSession.setThinkingLevel(options.defaultThinkingLevel as any); promptableSession,
} selectedModel ? `${selectedModel.provider}/${selectedModel.id}` : describeModel(promptableSession),
);
// Wire up event listeners // Wire up event listeners
promptableSession.subscribe((event) => { promptableSession.subscribe((event) => {