mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
feat(webui): drag sessions into composer mentions
This commit is contained in:
parent
5c4c2cb819
commit
2c8e63446f
@ -40,6 +40,7 @@ import {
|
||||
visibleSessionsForGroup,
|
||||
type ChatGroupLabels,
|
||||
} from "@/lib/chat-groups";
|
||||
import { writeDraggedSession } from "@/lib/session-drag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
|
||||
@ -276,10 +277,19 @@ export const ChatList = memo(function ChatList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
draggable={!active}
|
||||
onDragStart={(event) => {
|
||||
if (active) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
writeDraggedSession(event.dataTransfer, s.key);
|
||||
}}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={tooltipTitle}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 overflow-hidden text-left",
|
||||
!active && "cursor-grab active:cursor-grabbing",
|
||||
compact ? "py-1" : "py-1.5",
|
||||
projectMode && "pl-7",
|
||||
)}
|
||||
|
||||
@ -105,6 +105,10 @@ import {
|
||||
isSideChannelLifecycle,
|
||||
slashCommandLifecycle,
|
||||
} from "@/lib/slash-command";
|
||||
import {
|
||||
hasDraggedSession,
|
||||
readDraggedSession,
|
||||
} from "@/lib/session-drag";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -1606,10 +1610,13 @@ export function ThreadComposer({
|
||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
|
||||
);
|
||||
|
||||
const chooseMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate) => {
|
||||
if (!cliAppMention) return;
|
||||
const insertMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate, start: number, end: number) => {
|
||||
if (candidate.kind === "session") {
|
||||
const alreadySelected = activeSessionMentions.some(
|
||||
(mention) => mention.session_key === candidate.mention.session_key,
|
||||
);
|
||||
if (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT) return;
|
||||
const name = candidate.name.toLowerCase();
|
||||
setSelectedSessionMentions([
|
||||
...activeSessionMentions.filter((mention) => (
|
||||
@ -1619,10 +1626,13 @@ export function ThreadComposer({
|
||||
candidate.mention,
|
||||
]);
|
||||
}
|
||||
const suffix = value.slice(cliAppMention.end);
|
||||
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
|
||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
||||
const nextCursor = cliAppMention.start + mention.length;
|
||||
const prefix = value.slice(0, start);
|
||||
const suffix = value.slice(end);
|
||||
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : "";
|
||||
const trailingSpace = /^\s/.test(suffix) ? "" : " ";
|
||||
const mention = `${leadingSpace}@${candidate.name}${trailingSpace}`;
|
||||
const next = `${prefix}${mention}${suffix}`;
|
||||
const nextCursor = prefix.length + mention.length;
|
||||
setValue(next);
|
||||
setCursorPosition(nextCursor);
|
||||
setCliAppMenuDismissed(true);
|
||||
@ -1636,9 +1646,40 @@ export function ThreadComposer({
|
||||
el.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
},
|
||||
[activeSessionMentions, cliAppMention, resizeTextarea, value],
|
||||
[activeSessionMentions, resizeTextarea, value],
|
||||
);
|
||||
|
||||
const chooseMentionCandidate = useCallback(
|
||||
(candidate: MentionCandidate) => {
|
||||
if (!cliAppMention) return;
|
||||
insertMentionCandidate(candidate, cliAppMention.start, cliAppMention.end);
|
||||
},
|
||||
[cliAppMention, insertMentionCandidate],
|
||||
);
|
||||
|
||||
const handleSessionDrop = useCallback((event: React.DragEvent) => {
|
||||
if (!hasDraggedSession(event.dataTransfer)) return false;
|
||||
event.preventDefault();
|
||||
if (disabled) return true;
|
||||
const sessionKey = readDraggedSession(event.dataTransfer);
|
||||
const mention = availableSessionMentions.find(
|
||||
(candidate) => candidate.session_key === sessionKey,
|
||||
);
|
||||
if (!mention) return true;
|
||||
const caret = textareaRef.current?.selectionStart ?? value.length;
|
||||
insertMentionCandidate(
|
||||
{
|
||||
kind: "session",
|
||||
name: mention.name,
|
||||
displayName: mention.title || mention.name,
|
||||
mention,
|
||||
},
|
||||
caret,
|
||||
textareaRef.current?.selectionEnd ?? caret,
|
||||
);
|
||||
return true;
|
||||
}, [availableSessionMentions, disabled, insertMentionCandidate, value.length]);
|
||||
|
||||
const clearComposerText = useCallback((restoreFocus = true) => {
|
||||
setValue("");
|
||||
setSelectedSessionMentions([]);
|
||||
@ -2068,9 +2109,18 @@ export function ThreadComposer({
|
||||
submit();
|
||||
}}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragOver={(event) => {
|
||||
if (hasDraggedSession(event.dataTransfer)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
} else {
|
||||
onDragOver(event);
|
||||
}
|
||||
}}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onDrop={(event) => {
|
||||
if (!handleSessionDrop(event)) onDrop(event);
|
||||
}}
|
||||
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||
>
|
||||
{showSlashMenu ? (
|
||||
|
||||
18
webui/src/lib/session-drag.ts
Normal file
18
webui/src/lib/session-drag.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
||||
|
||||
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
||||
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
||||
}
|
||||
|
||||
export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
||||
const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim();
|
||||
return sessionKey || null;
|
||||
}
|
||||
|
||||
export function writeDraggedSession(
|
||||
dataTransfer: DataTransfer,
|
||||
sessionKey: string,
|
||||
): void {
|
||||
dataTransfer.effectAllowed = "copy";
|
||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ChatList } from "@/components/ChatList";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||
@ -47,6 +48,39 @@ describe("ChatList", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("exposes inactive chats as session mention drag sources", () => {
|
||||
const dataTransfer = {
|
||||
effectAllowed: "",
|
||||
setData: vi.fn(),
|
||||
};
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[
|
||||
session({ chatId: "active", title: "Active chat" }),
|
||||
session({ chatId: "reference", title: "Reference chat" }),
|
||||
]}
|
||||
activeKey="websocket:active"
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Active chat" }))
|
||||
.toHaveAttribute("draggable", "false");
|
||||
const reference = screen.getByRole("button", { name: "Reference chat" });
|
||||
expect(reference).toHaveAttribute("draggable", "true");
|
||||
|
||||
fireEvent.dragStart(reference, { dataTransfer });
|
||||
|
||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
||||
SESSION_DRAG_TYPE,
|
||||
"websocket:reference",
|
||||
);
|
||||
});
|
||||
|
||||
it("orders chats by latest session activity by default", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
|
||||
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||
import type { ChatSummary, CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
||||
|
||||
vi.mock("@/lib/imageEncode", () => ({
|
||||
@ -1593,6 +1594,47 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a dropped sidebar session into the shared structured mention", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[session("pricing", "收费设计", "讨论云存储")]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Compare notes" } });
|
||||
input.setSelectionRange(7, 7);
|
||||
const dataTransfer = {
|
||||
types: [SESSION_DRAG_TYPE],
|
||||
effectAllowed: "copy",
|
||||
dropEffect: "none",
|
||||
files: [],
|
||||
getData: (type: string) => (
|
||||
type === SESSION_DRAG_TYPE ? "websocket:pricing" : ""
|
||||
),
|
||||
};
|
||||
|
||||
fireEvent.dragEnter(input, { dataTransfer });
|
||||
fireEvent.dragOver(input, { dataTransfer });
|
||||
fireEvent.drop(input, { dataTransfer });
|
||||
|
||||
expect(input).toHaveValue("Compare @收费设计 notes");
|
||||
expect(screen.getByTestId("composer-session-mention-收费设计"))
|
||||
.toHaveTextContent("@收费设计");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
expect(onSend).toHaveBeenCalledWith("Compare @收费设计 notes", undefined, {
|
||||
sessionMentions: [{
|
||||
name: "收费设计",
|
||||
session_key: "websocket:pricing",
|
||||
title: "收费设计",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("disambiguates duplicate and capability-colliding session names", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user