mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
fix(webui): polish session titles and status
This commit is contained in:
parent
361f31c0e4
commit
2f323e24c1
@ -17,6 +17,7 @@ import {
|
|||||||
loadSavedSecret,
|
loadSavedSecret,
|
||||||
saveSecret,
|
saveSecret,
|
||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
@ -391,8 +392,7 @@ function Shell({
|
|||||||
|
|
||||||
const headerTitle = activeSession
|
const headerTitle = activeSession
|
||||||
? activeSession.title ||
|
? activeSession.title ||
|
||||||
activeSession.preview ||
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
|
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@ -64,8 +65,11 @@ export function ChatList({
|
|||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
id: s.chatId.slice(0, 6),
|
id: s.chatId.slice(0, 6),
|
||||||
});
|
});
|
||||||
const rawLabel = (s.title || s.preview)?.trim();
|
const generatedTitle = s.title?.trim() || "";
|
||||||
const title = rawLabel || fallbackTitle;
|
const title =
|
||||||
|
generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
|
||||||
|
const tooltipTitle =
|
||||||
|
generatedTitle || deriveTitle(s.preview, fallbackTitle);
|
||||||
return (
|
return (
|
||||||
<li key={s.key} className="min-w-0">
|
<li key={s.key} className="min-w-0">
|
||||||
<div
|
<div
|
||||||
@ -79,7 +83,7 @@ export function ChatList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(s.key)}
|
onClick={() => onSelect(s.key)}
|
||||||
title={rawLabel || fallbackTitle}
|
title={tooltipTitle}
|
||||||
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
|
||||||
>
|
>
|
||||||
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
||||||
|
|||||||
@ -36,21 +36,25 @@ export function ConnectionBadge() {
|
|||||||
status === "connecting" ||
|
status === "connecting" ||
|
||||||
status === "reconnecting" ||
|
status === "reconnecting" ||
|
||||||
status === "error";
|
status === "error";
|
||||||
|
const label = t(`connection.${status}`);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
|
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||||
|
"text-muted-foreground/70 hover:bg-sidebar-accent/65",
|
||||||
meta.color,
|
meta.color,
|
||||||
)}
|
)}
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
role="status"
|
||||||
|
title={label}
|
||||||
>
|
>
|
||||||
<span className="relative flex h-1.5 w-1.5" aria-hidden>
|
<span className="relative flex h-2 w-2" aria-hidden>
|
||||||
{pulsing && (
|
{pulsing && (
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
|
||||||
)}
|
)}
|
||||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-current" />
|
||||||
</span>
|
</span>
|
||||||
{t(`connection.${status}`)}
|
<span className="sr-only">{label}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-sidebar-border/50" />
|
<Separator className="bg-sidebar-border/50" />
|
||||||
<div className="space-y-1 px-2.5 py-2.5 text-xs">
|
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={props.onOpenSettings}
|
onClick={props.onOpenSettings}
|
||||||
className="h-8 w-full justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||||
>
|
>
|
||||||
<Settings className="h-3.5 w-3.5" aria-hidden />
|
<Settings className="h-3.5 w-3.5" aria-hidden />
|
||||||
{t("sidebar.settings")}
|
{t("sidebar.settings")}
|
||||||
|
|||||||
@ -167,8 +167,9 @@ export function ThreadShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
return client.onSessionUpdate((updatedChatId) => {
|
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||||
if (updatedChatId !== chatId) return;
|
if (updatedChatId !== chatId) return;
|
||||||
|
if (scope === "metadata") return;
|
||||||
pendingCanonicalHydrateRef.current.add(chatId);
|
pendingCanonicalHydrateRef.current.add(chatId);
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,10 +1,35 @@
|
|||||||
import i18n, { currentLocale } from "@/i18n";
|
import i18n, { currentLocale } from "@/i18n";
|
||||||
|
|
||||||
|
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
|
||||||
|
"hi",
|
||||||
|
"hello",
|
||||||
|
"hey",
|
||||||
|
"hello nano",
|
||||||
|
"hello nanobot",
|
||||||
|
"hi nano",
|
||||||
|
"hi nanobot",
|
||||||
|
"你好",
|
||||||
|
"您好",
|
||||||
|
"嗨",
|
||||||
|
"哈喽",
|
||||||
|
"哈啰",
|
||||||
|
"在吗",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isLowInformationTitlePreview(text: string): boolean {
|
||||||
|
const normalized = text.toLowerCase().replace(/[.!?。!?~~\s]+$/g, "").trim();
|
||||||
|
return (
|
||||||
|
normalized.startsWith("/") ||
|
||||||
|
LOW_INFORMATION_TITLE_PREVIEWS.has(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Truncate the first user message into a chat title. */
|
/** Truncate the first user message into a chat title. */
|
||||||
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
export function deriveTitle(preview: string | undefined, fallback: string): string {
|
||||||
if (!preview) return fallback;
|
if (!preview) return fallback;
|
||||||
const oneLine = preview.replace(/\s+/g, " ").trim();
|
const oneLine = preview.replace(/\s+/g, " ").trim();
|
||||||
if (!oneLine) return fallback;
|
if (!oneLine) return fallback;
|
||||||
|
if (isLowInformationTitlePreview(oneLine)) return fallback;
|
||||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,7 +54,8 @@ type Unsubscribe = () => void;
|
|||||||
type EventHandler = (ev: InboundEvent) => void;
|
type EventHandler = (ev: InboundEvent) => void;
|
||||||
type StatusHandler = (status: ConnectionStatus) => void;
|
type StatusHandler = (status: ConnectionStatus) => void;
|
||||||
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
|
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
|
||||||
type SessionUpdateHandler = (chatId: string) => void;
|
type SessionUpdateScope = "metadata" | "thread" | string;
|
||||||
|
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
|
||||||
|
|
||||||
/** Structured connection-level errors surfaced to the UI.
|
/** Structured connection-level errors surfaced to the UI.
|
||||||
*
|
*
|
||||||
@ -364,7 +365,7 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.event === "session_updated") {
|
if (parsed.event === "session_updated") {
|
||||||
this.emitSessionUpdate(parsed.chat_id);
|
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,9 +383,9 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitSessionUpdate(chatId: string): void {
|
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
|
||||||
for (const handler of this.sessionUpdateHandlers) {
|
for (const handler of this.sessionUpdateHandlers) {
|
||||||
handler(chatId);
|
handler(chatId, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -233,9 +233,13 @@ describe("NanobotClient", () => {
|
|||||||
client.connect();
|
client.connect();
|
||||||
lastSocket().fakeOpen();
|
lastSocket().fakeOpen();
|
||||||
|
|
||||||
lastSocket().fakeMessage({ event: "session_updated", chat_id: "chat-title" });
|
lastSocket().fakeMessage({
|
||||||
|
event: "session_updated",
|
||||||
|
chat_id: "chat-title",
|
||||||
|
scope: "metadata",
|
||||||
|
});
|
||||||
|
|
||||||
expect(globalHandler).toHaveBeenCalledWith("chat-title");
|
expect(globalHandler).toHaveBeenCalledWith("chat-title", "metadata");
|
||||||
expect(chatHandler).not.toHaveBeenCalled();
|
expect(chatHandler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type { UIMessage } from "@/lib/types";
|
|||||||
function makeClient() {
|
function makeClient() {
|
||||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
@ -34,7 +34,7 @@ function makeClient() {
|
|||||||
errorHandlers.delete(handler);
|
errorHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => {
|
return () => {
|
||||||
sessionUpdateHandlers.delete(handler);
|
sessionUpdateHandlers.delete(handler);
|
||||||
@ -49,8 +49,8 @@ function makeClient() {
|
|||||||
}
|
}
|
||||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||||
},
|
},
|
||||||
_emitSessionUpdate(chatId: string) {
|
_emitSessionUpdate(chatId: string, scope?: string) {
|
||||||
for (const h of sessionUpdateHandlers) h(chatId);
|
for (const h of sessionUpdateHandlers) h(chatId, scope);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
@ -651,6 +651,52 @@ describe("ThreadShell", () => {
|
|||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not refetch thread history for metadata-only session updates", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
return httpJson(
|
||||||
|
transcriptFromSimpleMessages([
|
||||||
|
{ role: "user", content: "question" },
|
||||||
|
{ role: "assistant", content: "answer" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-a")}
|
||||||
|
title="Chat chat-a"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||||
|
expect(historyCalls).toBe(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client._emitSessionUpdate("chat-a", "metadata");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(historyCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const scrollIntoView = vi.fn();
|
const scrollIntoView = vi.fn();
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { useSessionHistory, useSessions } from "@/hooks/useSessions";
|
import { sessionTitle, useSessionHistory, useSessions } from "@/hooks/useSessions";
|
||||||
import * as api from "@/lib/api";
|
import * as api from "@/lib/api";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function fakeClient() {
|
function fakeClient() {
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
@ -25,12 +25,12 @@ function fakeClient() {
|
|||||||
onError: () => () => {},
|
onError: () => () => {},
|
||||||
onChat: () => () => {},
|
onChat: () => () => {},
|
||||||
getRunStartedAt: () => null,
|
getRunStartedAt: () => null,
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string, scope?: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => sessionUpdateHandlers.delete(handler);
|
return () => sessionUpdateHandlers.delete(handler);
|
||||||
},
|
},
|
||||||
emitSessionUpdate: (chatId: string) => {
|
emitSessionUpdate: (chatId: string, scope?: string) => {
|
||||||
for (const handler of sessionUpdateHandlers) handler(chatId);
|
for (const handler of sessionUpdateHandlers) handler(chatId, scope);
|
||||||
},
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
@ -61,6 +61,28 @@ describe("useSessions", () => {
|
|||||||
vi.mocked(api.fetchWebuiThread).mockReset();
|
vi.mocked(api.fetchWebuiThread).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not use low-information greetings as fallback session titles", () => {
|
||||||
|
expect(sessionTitle({
|
||||||
|
key: "websocket:chat-hi",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-hi",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
|
title: "",
|
||||||
|
preview: "hi",
|
||||||
|
})).toBe("New chat");
|
||||||
|
|
||||||
|
expect(sessionTitle({
|
||||||
|
key: "websocket:chat-work",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-work",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
|
title: "",
|
||||||
|
preview: "帮我优化 WebUI 性能",
|
||||||
|
})).toBe("帮我优化 WebUI 性能");
|
||||||
|
});
|
||||||
|
|
||||||
it("removes a session from the local list after delete succeeds", async () => {
|
it("removes a session from the local list after delete succeeds", async () => {
|
||||||
vi.mocked(api.listSessions).mockResolvedValue([
|
vi.mocked(api.listSessions).mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user