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
120 changes: 118 additions & 2 deletions coding-agent/webui/app/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,131 @@ describe("App session switching", () => {
sessionsUrl = url;
return Promise.resolve(jsonResponse({ sessions: [] }));
}
if (url.endsWith("/healthz")) {
return Promise.resolve(jsonResponse({ status: "ok", sessions: 0, version: "0.0.0" }));
}
throw new Error(`unexpected fetch ${url}`);
}),
);

render(<App />);

const baseUrl = screen.getByTitle("Server base URL") as HTMLInputElement;
await waitFor(() => expect(sessionsUrl).toBe(`${window.location.origin}/sessions`));
expect(baseUrl.value).toBe(window.location.origin);
// The default same-origin profile is seeded and persisted.
const stored = JSON.parse(
localStorage.getItem("coding-agent-webui-profiles") ?? "{}",
) as { profiles?: Array<{ baseUrl?: string }> };
expect(stored.profiles?.[0]?.baseUrl).toBe(window.location.origin);
});

it("switching the active profile swaps the backend and refreshes the session list", async () => {
const onA = session("session-a-0001", "model-a");
const onB = session("session-b-0002", "model-b");
localStorage.setItem(
"coding-agent-webui-profiles",
JSON.stringify({
profiles: [
{ id: "pa", name: "backend-a", baseUrl: "http://a.test", apiKey: "" },
{ id: "pb", name: "backend-b", baseUrl: "http://b.test", apiKey: "" },
],
activeId: "pa",
}),
);
const sessionListCalls: string[] = [];

vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "http://a.test/sessions") {
sessionListCalls.push(url);
return Promise.resolve(jsonResponse({ sessions: [onA] }));
}
if (url === "http://b.test/sessions") {
sessionListCalls.push(url);
return Promise.resolve(jsonResponse({ sessions: [onB] }));
}
if (url.endsWith("/healthz")) {
return Promise.resolve(jsonResponse({ status: "ok", sessions: 1, version: "1.0.0" }));
}
if (url.endsWith("/oauth/accounts")) {
return Promise.resolve(jsonResponse([]));
}
throw new Error(`unexpected fetch ${url}`);
}),
);

render(<App />);

// Backend A is active on load.
expect(await screen.findByRole("button", { name: /model-a/i })).toBeTruthy();
expect(screen.getByRole("button", { name: "connection" }).textContent).toContain(
"backend-a",
);

fireEvent.click(screen.getByRole("button", { name: "connection" }));
fireEvent.click(await screen.findByRole("button", { name: "Switch" }));

// The session list was cleared and re-fetched from backend B.
await waitFor(() => expect(sessionListCalls).toContain("http://b.test/sessions"));
expect(await screen.findByRole("button", { name: /model-b/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: /model-a/i })).toBeNull();
expect(screen.getByRole("button", { name: "connection" }).textContent).toContain(
"backend-b",
);
});

it("discards a stale session list response from the old backend after a profile switch", async () => {
const onA = session("session-a-0001", "model-a");
const onB = session("session-b-0002", "model-b");
localStorage.setItem(
"coding-agent-webui-profiles",
JSON.stringify({
profiles: [
{ id: "pa", name: "backend-a", baseUrl: "http://a.test", apiKey: "" },
{ id: "pb", name: "backend-b", baseUrl: "http://b.test", apiKey: "" },
],
activeId: "pa",
}),
);
const oldList = deferred<Response>();

vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === "http://a.test/sessions") {
// Old backend is slow: its list response is still in flight when the
// profile switch happens.
return oldList.promise;
}
if (url === "http://b.test/sessions") {
return Promise.resolve(jsonResponse({ sessions: [onB] }));
}
if (url.endsWith("/healthz")) {
return Promise.resolve(jsonResponse({ status: "ok", sessions: 1, version: "1.0.0" }));
}
if (url.endsWith("/oauth/accounts")) {
return Promise.resolve(jsonResponse([]));
}
throw new Error(`unexpected fetch ${url}`);
}),
);

render(<App />);

// The initial list request to backend A is in flight (unresolved).
fireEvent.click(screen.getByRole("button", { name: "connection" }));
fireEvent.click(await screen.findByRole("button", { name: "Switch" }));

// Backend B's list wins.
expect(await screen.findByRole("button", { name: /model-b/i })).toBeTruthy();

// The stale response from backend A resolves late and must be discarded.
oldList.resolve(jsonResponse({ sessions: [onA] }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.queryByRole("button", { name: /model-a/i })).toBeNull();
expect(screen.getByRole("button", { name: /model-b/i })).toBeTruthy();
});

it("uses prompt for completed restored sessions even when historical resume metadata remains", async () => {
Expand Down
81 changes: 67 additions & 14 deletions coding-agent/webui/app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AgentClient, type RuntimeConfigPatch } from "./lib/api";
import {
activeProfile,
defaultBaseUrl,
loadProfiles,
saveProfiles,
type ProfileStore,
} from "./lib/profiles";
import type {
ApprovalPolicy,
ApprovalScope,
Expand Down Expand Up @@ -47,14 +54,6 @@ type Config = {
model: string;
};

function defaultBaseUrl(): string {
const configured = import.meta.env.VITE_API_BASE_URL;
if (typeof configured === "string" && configured.trim()) {
return configured.trim();
}
return window.location.origin;
}

function loadConfig(): Config {
const defaults: Config = {
baseUrl: defaultBaseUrl(),
Expand All @@ -64,13 +63,21 @@ function loadConfig(): Config {
provider: "kimi-code",
model: "kimi-for-coding",
};
let merged = defaults;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) return { ...defaults, ...(JSON.parse(raw) as Partial<Config>) };
if (raw) merged = { ...defaults, ...(JSON.parse(raw) as Partial<Config>) };
} catch {
/* ignore */
}
return defaults;
// The active connection profile owns baseUrl/apiKey; the legacy config's
// copies are only migration input for the profiles store.
const active = activeProfile(loadProfiles());
if (active) {
merged.baseUrl = active.baseUrl;
merged.apiKey = active.apiKey;
}
return merged;
}

function loadTheme(): Theme {
Expand Down Expand Up @@ -98,6 +105,7 @@ function initialSidebarOpen(): boolean {

export default function App() {
const [config, setConfig] = useState<Config>(loadConfig);
const [profiles, setProfiles] = useState<ProfileStore>(loadProfiles);
const [theme, setTheme] = useState<Theme>(loadTheme);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionMode, setSessionMode] = useState<"prompt" | "resume">("prompt");
Expand Down Expand Up @@ -125,6 +133,7 @@ export default function App() {
const abortRef = useRef<AbortController | null>(null);
const followAbortRef = useRef<AbortController | null>(null);
const loadSeqRef = useRef(0);
const sessionsSeqRef = useRef(0);
const memorySeqRef = useRef(0);
const resultSeqRef = useRef(0);
const diffSeqRef = useRef(0);
Expand Down Expand Up @@ -199,20 +208,62 @@ export default function App() {
setSessionLoading(value);
}, []);

// Persist profile changes and, when the active connection (baseUrl/apiKey)
// actually changed, switch backend: drop all session state tied to the old
// server and push the new connection through the normal config path (which
// recreates the client and re-fetches the session list).
const handleProfilesChange = useCallback(
(next: ProfileStore) => {
saveProfiles(next);
setProfiles(next);
const active = activeProfile(next);
if (!active) return;
if (active.baseUrl === config.baseUrl && active.apiKey === config.apiKey) return;
loadSeqRef.current += 1;
// Invalidate any in-flight session list fetch from the old backend so its
// late response cannot overwrite the new backend's list.
sessionsSeqRef.current += 1;
abortRef.current?.abort();
abortRef.current = null;
followAbortRef.current?.abort();
followAbortRef.current = null;
activeSessionRef.current = null;
setSessionId(null);
setSessionMode("prompt");
setItems([]);
setSessions([]);
setDiffState(null);
setMemoryState(null);
setResultState(null);
setRailPanel(null);
setStreaming(false);
setSessionLoadingState(false);
setStatus("no session");
patchConfig({ baseUrl: active.baseUrl, apiKey: active.apiKey });
},
[config.baseUrl, config.apiKey, patchConfig, setSessionLoadingState],
);

const refreshSessions = useCallback(async () => {
// Refreshes can resolve out of order (e.g. a slow old backend vs. a fresh
// fetch after a profile switch); only the latest call may apply. Same
// seq-guard pattern as refreshMemory/refreshResult.
const sessionsSeq = ++sessionsSeqRef.current;
setSessionsLoading(true);
setSessionsError(null);
try {
const listed = await client.listSessions();
if (sessionsSeqRef.current !== sessionsSeq) return;
listed.sort(
(a, b) =>
new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime(),
);
setSessions(listed);
} catch (e) {
if (sessionsSeqRef.current !== sessionsSeq) return;
setSessionsError(`session list failed: ${msg(e)}`);
} finally {
setSessionsLoading(false);
if (sessionsSeqRef.current === sessionsSeq) setSessionsLoading(false);
}
}, [client]);

Expand Down Expand Up @@ -261,10 +312,10 @@ export default function App() {
}, [client]);

useEffect(() => {
// refreshSessions is keyed on the client, so this also re-fetches after a
// profile switch or connection edit recreates the client.
void refreshSessions();
// Config edits can pass through incomplete URLs; refresh explicitly after edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [refreshSessions]);

const newSession = useCallback(async () => {
const loadSeq = loadSeqRef.current + 1;
Expand Down Expand Up @@ -644,6 +695,8 @@ export default function App() {
<Header
config={config}
onConfigChange={patchConfig}
profiles={profiles}
onProfilesChange={handleProfilesChange}
onNewSession={newSession}
onToggleSidebar={() => setSidebarOpen((v) => !v)}
sessionId={sessionId}
Expand Down
Loading
Loading