Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ function AppInner() {
const [supportedUpdateChannels, setSupportedUpdateChannels] = useState<UpdateChannel[]>([]);

useEffect(() => {
setSupportedUpdateChannels([]);
// No reset here: a reconnect (new client) keeps the listed channels until the reload answers.
if (!api) return;

let active = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe("WorkspaceHeartbeatModal", () => {
? createHeartbeatSettings()
: (settingsByWorkspaceId.get(workspaceId) ?? createHeartbeatSettings()),
isLoading: hookIsLoading,
isReloading: false,
isSaving: hookIsSaving,
error: hookError,
globalDefaultPrompt: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,21 @@ function getDraftMessageForSave(value: string): string {
return normalizeDraftMessage(value) ?? "";
}

/** A save waits for loads (a reconnect's read too: it would overwrite offline changes). */
function isSaveBlocked(state: {
isLoading: boolean;
isReloading: boolean;
isSaving: boolean;
hasValidationError: boolean;
}): boolean {
return state.isLoading || state.isReloading || state.isSaving || state.hasValidationError;
}

export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const {
settings,
isLoading,
isReloading,
isSaving,
error,
save,
Expand Down Expand Up @@ -239,7 +250,12 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const errorMessages = [validationError, error].filter(
(message): message is string => message != null
);
const hasBlockingError = isLoading || isSaving || validationError != null;
const hasBlockingError = isSaveBlocked({
isLoading,
isReloading,
isSaving,
hasValidationError: validationError != null,
});

const handleIntervalBlur = () => {
const parsedMinutes = parseIntervalMinutes(draftIntervalMinutes);
Expand Down
85 changes: 85 additions & 0 deletions src/browser/contexts/AgentContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { AgentProvider, useAgent, type AgentContextValue } from "./AgentContext";
import { APIProvider, type APIClient } from "./API";
import {
createConnectionHarness,
GAP_MODES,
type ConnectionHarness,
} from "./connection.testHarness";
import { ProjectProvider } from "./ProjectContext";
import { RouterProvider } from "./RouterContext";
import { WorkspaceProvider } from "./WorkspaceContext";
import {
createTestApiClient,
createTestConfig,
createTestPreferencesConfig,
type TestApiOverrides,
} from "@/browser/testUtils";
import type { AgentAiDefaults } from "@/common/types/agentAiDefaults";
import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync";
Expand Down Expand Up @@ -512,3 +518,82 @@ describe("AgentContext", () => {
}
});
});

// A reconnect brings a new client, so the agents load again. The picker keeps the agents it shows
// until that reload answers; only another workspace or project clears them.
describe.each([...GAP_MODES])("AgentContext across a reconnect (%s)", (mode) => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;
let harness: ConnectionHarness;
/** The agents the next server lists, or a promise that answers later. */
let serverAgents: AgentDefinitionDescriptor[] | Promise<AgentDefinitionDescriptor[]>;
let latest: AgentContextValue | undefined;

beforeEach(() => {
originalWindow = globalThis.window;
originalDocument = globalThis.document;
const dom = new GlobalWindow();
globalThis.window = dom as unknown as Window & typeof globalThis;
globalThis.document = dom.document as unknown as Document;
latest = undefined;
serverAgents = [EXEC_AGENT];
harness = createConnectionHarness(mode, (): TestApiOverrides<APIClient> => {
const answer = serverAgents;
return { agents: { list: () => Promise.resolve(answer) } };
});
});

afterEach(() => {
cleanup();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
});

function renderAgents(workspaceId: string) {
const tree = (id: string) => (
<harness.Wrapper>
<AgentProvider workspaceId={id} projectPath="/tmp/project">
<Harness onChange={(value) => (latest = value)} />
</AgentProvider>
</harness.Wrapper>
);
const view = render(tree(workspaceId));
return { rerender: (id: string) => view.rerender(tree(id)) };
}

test("keeps the shown agents until the reload answers", async () => {
renderAgents("ws-a");
await waitFor(() => expect(latest?.agents.map((agent) => agent.id)).toEqual(["exec"]));

let answer: ((agents: AgentDefinitionDescriptor[]) => void) | null = null;
serverAgents = new Promise((resolve) => (answer = resolve));
await harness.drop();
harness.reconnect();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});

expect(latest?.loaded).toBe(true);
expect(latest?.loadFailed).toBe(false);
expect(latest?.agents.map((agent) => agent.id)).toEqual(["exec"]);

act(() => answer?.([EXEC_AGENT, PLAN_AGENT]));
await waitFor(() => expect(latest?.agents.map((agent) => agent.id)).toEqual(["exec", "plan"]));
});

test("a workspace switch while offline clears the shown agents", async () => {
const view = renderAgents("ws-a");
await waitFor(() => expect(latest?.agents.map((agent) => agent.id)).toEqual(["exec"]));

await harness.drop();
view.rerender("ws-b");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
expect(latest?.agents).toEqual([]);

serverAgents = [PLAN_AGENT];
harness.reconnect();
await waitFor(() => expect(latest?.agents.map((agent) => agent.id)).toEqual(["plan"]));
});
});
44 changes: 27 additions & 17 deletions src/browser/contexts/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "react";

import { useAPI } from "@/browser/contexts/API";
import { isConnectionLostError } from "@/browser/contexts/connectionLink";
import { useOptionalWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
Expand Down Expand Up @@ -65,6 +66,14 @@ function getScopeId(workspaceId: string | undefined, projectPath: string | undef
return workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID);
}

function isSameFetch(
current: { projectPath: string | undefined; workspaceId: string | undefined },
projectPath: string | undefined,
workspaceId: string | undefined
): boolean {
return current.projectPath === projectPath && current.workspaceId === workspaceId;
}

function coerceAgentId(value: unknown): string {
return normalizeAgentId(value, WORKSPACE_DEFAULTS.agentId);
}
Expand Down Expand Up @@ -144,7 +153,9 @@ function AgentProviderWithState(props: {

if (!api || (!projectPath && !workspaceId)) {
if (isMountedRef.current) {
setAgents([]);
// No setAgents([]): offline, keep the agents shown for this scope, the reconnect
// reloads them. A scope change (also to no scope) already cleared them in the load
// effect below.
setLoaded(true);
setLoadFailed(false);
}
Expand All @@ -153,24 +164,16 @@ function AgentProviderWithState(props: {

try {
const result = await api.agents.list({ projectPath, workspaceId });
const current = fetchParamsRef.current;
if (
current.projectPath === projectPath &&
current.workspaceId === workspaceId &&
isMountedRef.current
) {
if (isSameFetch(fetchParamsRef.current, projectPath, workspaceId) && isMountedRef.current) {
setAgents(result);
setAgentBases(getScopeId(workspaceId, projectPath), result);
setLoadFailed(false);
setLoaded(true);
}
} catch {
const current = fetchParamsRef.current;
if (
current.projectPath === projectPath &&
current.workspaceId === workspaceId &&
isMountedRef.current
) {
} catch (error) {
// A lost connection (or the reconnect that replaced the client) is no load failure.
if (isConnectionLostError(error)) return;
if (isSameFetch(fetchParamsRef.current, projectPath, workspaceId) && isMountedRef.current) {
setAgents([]);
setAgentBases(getScopeId(workspaceId, projectPath), []);
setLoadFailed(true);
Expand All @@ -181,10 +184,17 @@ function AgentProviderWithState(props: {
[api]
);

// The scope whose agents are shown. Only a new scope clears them: a new client (reconnect)
// reloads the same scope behind the shown agents.
const shownScopeRef = useRef<string | null>(null);
useEffect(() => {
setAgents([]);
setLoaded(false);
setLoadFailed(false);
const scope = JSON.stringify([props.projectPath, props.workspaceId]);
if (shownScopeRef.current !== scope) {
shownScopeRef.current = scope;
setAgents([]);
setLoaded(false);
setLoadFailed(false);
}
void fetchAgents(props.projectPath, props.workspaceId);
}, [fetchAgents, props.projectPath, props.workspaceId]);

Expand Down
16 changes: 11 additions & 5 deletions src/browser/contexts/ProjectContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getDraftScopeId } from "@/common/constants/storage";
import { deleteWorkspaceStorage } from "@/browser/utils/workspaceStorage";
import { getDraftStore } from "@/browser/stores/DraftStore";
import { getErrorMessage } from "@/common/utils/errors";
import { isConnectionLostError } from "@/browser/contexts/connectionLink";
import type { ProjectWorkspaceCounts } from "@/common/utils/projectRemoval";
import { getProjectRouteId } from "@/common/utils/projectRouteId";
import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch";
Expand Down Expand Up @@ -136,6 +137,10 @@ function toNonEmptyTrimmed(value?: string | null): string | null {
return trimmed && trimmed.length > 0 ? trimmed : null;
}

function findSystemProjectPath(projects: Map<string, ProjectConfig>): string | null {
return [...projects.entries()].find(([, cfg]) => cfg.projectKind === "system")?.[0] ?? null;
}

export function ProjectProvider(props: { children: ReactNode }) {
const { api } = useAPI();
// Handlers use only the live client: offline they keep returning their result (for example
Expand All @@ -149,9 +154,7 @@ export function ProjectProvider(props: { children: ReactNode }) {
[allProjectsInternal]
);
const systemProjectPath = useMemo(
() =>
[...allProjectsInternal.entries()].find(([, cfg]) => cfg.projectKind === "system")?.[0] ??
null,
() => findSystemProjectPath(allProjectsInternal),
[allProjectsInternal]
);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -180,7 +183,7 @@ export function ProjectProvider(props: { children: ReactNode }) {
const refresh = projectsRefreshRef.current;
if (refresh.api !== api) return Promise.resolve();
if (!api) {
setLoaded(false);
// Keep `loaded` and the shown projects: the reconnect reloads them (stale-while-revalidate).
setLoadError("API not connected");
return Promise.resolve();
}
Expand All @@ -200,6 +203,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
setLoadError(null);
} catch (error) {
if (refresh !== projectsRefreshRef.current || !refresh.api) continue;
// A lost connection is no load error: the reconnect reloads.
if (isConnectionLostError(error)) continue;
// Keep successful data when a later refresh fails.
console.error("Failed to load projects:", error);
setLoadError(getErrorMessage(error));
Expand Down Expand Up @@ -598,7 +603,8 @@ export function ProjectProvider(props: { children: ReactNode }) {
hasAnyProject,
resolveNewChatProjectPath,
getProjectConfig,
loading,
// Only the first load counts: a reload after a reconnect keeps the loaded projects shown.
loading: loading && !loaded,
loaded,
loadError,
refreshProjects,
Expand Down
83 changes: 83 additions & 0 deletions src/browser/contexts/UILayoutsContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Bootstrap Happy DOM before react-dom evaluates.
import "../../../tests/ui/dom";

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { installDom } from "../../../tests/ui/dom";
import type { APIClient } from "@/browser/contexts/API";
import {
createConnectionHarness,
GAP_MODES,
type ConnectionHarness,
} from "./connection.testHarness";
import type { TestApiOverrides } from "@/browser/testUtils";
import { normalizeLayoutPresetsConfig, type LayoutPresetsConfig } from "@/common/types/uiLayouts";
import { UILayoutsProvider, useUILayouts } from "./UILayoutsContext";

/** A config with one slot (a keybind override), so it differs from the defaults (no slots). */
function configWithSlot(slot: number): LayoutPresetsConfig {
const config = normalizeLayoutPresetsConfig({
version: 2,
slots: [{ slot, keybindOverride: { key: "F1", ctrl: true } }],
});
// Setup guard: the slot survives normalization, so the assertions below see it.
expect(config.slots.map((entry) => entry.slot)).toEqual([slot]);
return config;
}

let harness: ConnectionHarness;
/** What the next server answers for uiLayouts.getAll: a config, or a promise of one. */
let serverConfig: LayoutPresetsConfig | Promise<LayoutPresetsConfig>;
let shownSlots: number[] | null = null;

function SlotsProbe() {
const layouts = useUILayouts();
shownSlots = layouts.loaded ? layouts.layoutPresets.slots.map((entry) => entry.slot) : null;
return null;
}

// A reconnect (null-gap: a gap without a client) must keep the shown layouts until the new
// client's reload answers.
describe.each([...GAP_MODES])("UILayoutsProvider across a reconnect (%s)", (mode) => {
let cleanupDom: (() => void) | null = null;

beforeEach(() => {
cleanupDom = installDom();
shownSlots = null;
serverConfig = configWithSlot(1);
harness = createConnectionHarness(mode, (): TestApiOverrides<APIClient> => {
const answer = serverConfig;
return { uiLayouts: { getAll: () => Promise.resolve(answer) } };
});
});

afterEach(() => {
cleanup();
cleanupDom?.();
cleanupDom = null;
});

test("keeps the loaded layouts while offline and until the reload answers", async () => {
render(
<harness.Wrapper>
<UILayoutsProvider>
<SlotsProbe />
</UILayoutsProvider>
</harness.Wrapper>
);
await waitFor(() => expect(shownSlots).toEqual([1]));

let answer: ((config: LayoutPresetsConfig) => void) | null = null;
serverConfig = new Promise((resolve) => (answer = resolve));
await harness.drop();
expect(shownSlots).toEqual([1]);
harness.reconnect();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
expect(shownSlots).toEqual([1]);

act(() => answer?.(configWithSlot(2)));
await waitFor(() => expect(shownSlots).toEqual([2]));
});
});
Loading
Loading