feat(desktop): polish desktop shell and shared WebUI surfaces (#4195)
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
This commit is contained in:
@@ -118,8 +118,9 @@ const installedAnyGen = {
|
||||
|
||||
function renderSettingsView(
|
||||
options: {
|
||||
initialSection?: "apps" | "advanced" | "models";
|
||||
initialSection?: "overview" | "apps" | "advanced" | "models";
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
onNativeEngineRestart?: () => Promise<string>;
|
||||
} = {},
|
||||
) {
|
||||
render(
|
||||
@@ -131,6 +132,7 @@ function renderSettingsView(
|
||||
onBackToChat={() => {}}
|
||||
onModelNameChange={() => {}}
|
||||
onSettingsChange={options.onSettingsChange}
|
||||
onNativeEngineRestart={options.onNativeEngineRestart}
|
||||
/>
|
||||
</ClientProvider>,
|
||||
);
|
||||
@@ -219,6 +221,55 @@ describe("SettingsView Apps catalog", () => {
|
||||
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
|
||||
});
|
||||
|
||||
it("shows token activity on the overview", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
usage: {
|
||||
days: [
|
||||
{
|
||||
date: "2026-06-03",
|
||||
prompt_tokens: 1200,
|
||||
completion_tokens: 300,
|
||||
cached_tokens: 500,
|
||||
total_tokens: 1500,
|
||||
requests: 2,
|
||||
},
|
||||
],
|
||||
total_tokens: 1500,
|
||||
total_tokens_30d: 1500,
|
||||
total_tokens_365d: 1500,
|
||||
peak_day_tokens: 1500,
|
||||
current_streak_days: 1,
|
||||
longest_streak_days: 1,
|
||||
active_days_30d: 1,
|
||||
requests_30d: 2,
|
||||
updated_at: "2026-06-03T00:00:00Z",
|
||||
},
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "overview" });
|
||||
|
||||
expect(await screen.findByLabelText("Token activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Token Usage")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Token activity")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Total tokens")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows context window options in model settings", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -242,6 +293,280 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the current model as unconfigured when its provider needs setup", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "openai-codex/gpt-5.1-codex",
|
||||
provider: "openai_codex",
|
||||
resolved_provider: "openai_codex",
|
||||
has_api_key: false,
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "openai-codex/gpt-5.1-codex",
|
||||
provider: "openai_codex",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const configurationButton = await screen.findByRole("button", {
|
||||
name: "Current configuration",
|
||||
});
|
||||
expect(configurationButton).toHaveTextContent("Not configured");
|
||||
expect(configurationButton).toHaveTextContent("OpenAI Codex · openai-codex/gpt-5.1-codex");
|
||||
expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps unsigned OAuth providers out of the active provider picker", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "deepseek-chat",
|
||||
provider: "deepseek",
|
||||
resolved_provider: "deepseek",
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "deepseek-chat",
|
||||
provider: "deepseek",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "deepseek",
|
||||
label: "DeepSeek",
|
||||
configured: true,
|
||||
auth_type: "api_key",
|
||||
api_key_required: true,
|
||||
api_key_hint: "sk-...",
|
||||
api_base: "https://api.deepseek.com",
|
||||
default_api_base: "https://api.deepseek.com",
|
||||
},
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
{
|
||||
name: "github_copilot",
|
||||
label: "GitHub Copilot",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://api.githubcopilot.com",
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const deepseekButtons = await screen.findAllByRole("button", { name: /DeepSeek/ });
|
||||
const providerPicker = deepseekButtons.find(
|
||||
(button) => button.getAttribute("aria-haspopup") === "menu",
|
||||
);
|
||||
if (!providerPicker) throw new Error("provider picker was not found");
|
||||
fireEvent.pointerDown(providerPicker);
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /DeepSeek/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /OpenAI Codex/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /GitHub Copilot/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not fetch model lists for unsigned OAuth providers", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "",
|
||||
provider: "openai_codex",
|
||||
resolved_provider: "openai_codex",
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "",
|
||||
provider: "openai_codex",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
{
|
||||
name: "github_copilot",
|
||||
label: "GitHub Copilot",
|
||||
configured: false,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://api.githubcopilot.com",
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
|
||||
expect(
|
||||
await screen.findByText("Configure this provider before loading models."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).startsWith("/api/settings/provider-models"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("prefills manual model ids for configured OAuth providers", async () => {
|
||||
const payload: SettingsPayload = {
|
||||
...settingsPayload(),
|
||||
agent: {
|
||||
...settingsPayload().agent,
|
||||
model: "open-codex/gpt-5.5",
|
||||
provider: "openai_codex",
|
||||
resolved_provider: "openai_codex",
|
||||
},
|
||||
model_presets: [
|
||||
{
|
||||
...settingsPayload().model_presets[0],
|
||||
model: "open-codex/gpt-5.5",
|
||||
provider: "openai_codex",
|
||||
},
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: true,
|
||||
auth_type: "oauth",
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: null,
|
||||
oauth_account: "acct-test",
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
|
||||
expect(input.value).toBe("open-codex/gpt-5.5");
|
||||
|
||||
fireEvent.change(input, { target: { value: "openai-codex/gpt-5.5" } });
|
||||
expect(await screen.findByText("“openai-codex/gpt-5.5”")).toBeInTheDocument();
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) =>
|
||||
String(input).startsWith("/api/settings/provider-models"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("can close the new configuration dialog without trapping the settings page", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -443,4 +768,64 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes settings with a fresh token after native engine restart", async () => {
|
||||
const payload = {
|
||||
...settingsPayload(),
|
||||
surface: "native" as const,
|
||||
runtime_surface: "native" as const,
|
||||
runtime_capabilities: {
|
||||
can_restart_engine: true,
|
||||
can_pick_folder: true,
|
||||
can_open_logs: true,
|
||||
can_export_diagnostics: true,
|
||||
},
|
||||
};
|
||||
const restartedPayload = {
|
||||
...payload,
|
||||
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
|
||||
requires_restart: true,
|
||||
restart_required_sections: ["runtime"],
|
||||
};
|
||||
const refreshedPayload = {
|
||||
...restartedPayload,
|
||||
requires_restart: false,
|
||||
restart_required_sections: [],
|
||||
};
|
||||
const restartEngine = vi.fn(async () => "fresh-token");
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
|
||||
if (url === "/api/settings" && auth === "Bearer fresh-token") {
|
||||
return jsonResponse(refreshedPayload);
|
||||
}
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
|
||||
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
|
||||
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
|
||||
return jsonResponse(restartedPayload);
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView({
|
||||
initialSection: "advanced",
|
||||
onNativeEngineRestart: restartEngine,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("App safety")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer fresh-token" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user