feat(webui): refine output timeline and model controls (#4108)

* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
This commit is contained in:
Xubin Ren
2026-05-30 23:45:26 +08:00
committed by GitHub
parent b2e43955e3
commit 3dcf511c84
65 changed files with 4526 additions and 1428 deletions
+24 -29
View File
@@ -9,7 +9,7 @@ import {
import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => {
it("groups consecutive reasoning and tool rows into one cluster before the answer", () => {
it("groups consecutive reasoning and tool rows into one timeline before the answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -55,7 +55,7 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4");
});
it("starts a new activity cluster when the activity segment changes", () => {
it("keeps file edits as their own activity row inside a turn", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -95,14 +95,11 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
"r2",
]);
expect(units).toHaveLength(3);
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
});
it("does not split ordinary tool activity just because segment ids changed", () => {
@@ -146,7 +143,7 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(1);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"r2",
@@ -154,7 +151,7 @@ describe("ThreadMessages", () => {
]);
});
it("only marks the current activity cluster as live while streaming", () => {
it("only marks the current activity timeline as live while streaming", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -197,12 +194,10 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
});
it("folds final answer reasoning into the preceding activity cluster", () => {
it("folds final answer reasoning into the preceding activity timeline", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -234,21 +229,21 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0]).toMatchObject({ type: "cluster" });
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0]).toMatchObject({ type: "activity" });
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"a1-reasoning",
]);
expect(units[0].type === "cluster" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
expect(units[0].type === "activity" ? units[0].messages.at(-1)?.latencyMs : undefined).toBe(9_200);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "a1",
content: "final answer",
},
});
if (units[1].type === "single") {
if (units[1].type === "message") {
expect(units[1].message).not.toHaveProperty("reasoning");
}
@@ -290,12 +285,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "a1",
content: "partial answer",
@@ -340,12 +335,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "a1",
content: "Hong Kong is hot today.",
@@ -391,12 +386,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"prelude",
"tool",
]);
expect(units[1]).toMatchObject({
type: "single",
type: "message",
message: {
id: "final",
content: "Done. Open index.html to play.",
@@ -404,7 +399,7 @@ describe("ThreadMessages", () => {
});
});
it("passes assistant turn latency to the preceding completed activity cluster", () => {
it("passes assistant turn latency to the preceding completed activity timeline", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -496,7 +491,7 @@ describe("ThreadMessages", () => {
const flags = assistantCopyFlags(units);
const assistantFlags = units
.map((unit, index) =>
unit.type === "single" && unit.message.role === "assistant"
unit.type === "message" && unit.message.role === "assistant"
? [unit.message.id, flags[index]]
: null,
)