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:
Xubin Ren
2026-06-06 19:49:33 +08:00
committed by GitHub
co-authored by chengyongru
parent a1b9577224
commit ab9f49970d
103 changed files with 10483 additions and 1003 deletions
+120 -4
View File
@@ -1,10 +1,13 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRef } from "react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import {
HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW,
ThreadViewport,
type ThreadViewportHandle,
windowMessages,
} from "@/components/thread/ThreadViewport";
import type { UIMessage } from "@/lib/types";
@@ -35,6 +38,24 @@ function makeLongMessages(count: number): UIMessage[] {
}));
}
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
const viewportRef = useRef<ThreadViewportHandle | null>(null);
return (
<div>
<PromptNavigator
messages={messages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
<ThreadViewport
ref={viewportRef}
messages={messages}
isStreaming={false}
composer={<div />}
/>
</div>
);
}
describe("ThreadViewport", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
@@ -75,7 +96,9 @@ describe("ThreadViewport", () => {
});
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button).toHaveStyle({ bottom: "192px" });
const buttonPositioner = button.parentElement as HTMLElement;
expect(button).not.toHaveClass("-translate-x-1/2");
expect(buttonPositioner).toHaveStyle({ bottom: "192px" });
const composerDock = screen.getByTestId("thread-composer-dock");
composerDock.getBoundingClientRect = () =>
@@ -100,7 +123,7 @@ describe("ThreadViewport", () => {
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
});
expect(button).toHaveStyle({ bottom: "256px" });
expect(buttonPositioner).toHaveStyle({ bottom: "256px" });
} finally {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
}
@@ -207,7 +230,10 @@ describe("ThreadViewport", () => {
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Jump to prompt: message 3" }));
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument();
fireEvent.click(targetPrompt);
expect(scrollTo).toHaveBeenCalledWith({
top: 1064,
@@ -215,6 +241,96 @@ describe("ThreadViewport", () => {
});
});
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
const promptMessages = makeLongMessages(5);
const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 360,
});
});
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Prompts")).toBeInTheDocument();
expect(within(dialog).getByText("message 4")).toBeInTheDocument();
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search prompts" }), {
target: { value: "message 4" },
});
expect(within(dialog).queryByText("message 1")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" }));
expect(scrollTo).toHaveBeenCalledWith({
top: 1424,
behavior: "smooth",
});
});
it("expands the history window before jumping to an older prompt from the navigator", async () => {
const longMessages = makeLongMessages(300);
render(<ViewportWithPromptNavigator messages={longMessages} />);
expect(screen.queryByText("message 20")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 20" }));
await waitFor(() => expect(screen.getByText("message 20")).toBeInTheDocument());
});
it("renders the prompt rail for compact scroll ranges", async () => {
const promptMessages = makeLongMessages(3);
const { container } = render(
<ThreadViewport
messages={promptMessages}
isStreaming={false}
composer={<div />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 700 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
const promptEls = Array.from(
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
);
expect(promptEls).toHaveLength(3);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 50,
});
});
await act(async () => {
window.dispatchEvent(new Event("resize"));
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
});
it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
const promptMessages = makeLongMessages(100);
const { container } = render(