mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
feat(webui): drag sessions into composer mentions
This commit is contained in:
parent
5c4c2cb819
commit
2c8e63446f
@ -40,6 +40,7 @@ import {
|
|||||||
visibleSessionsForGroup,
|
visibleSessionsForGroup,
|
||||||
type ChatGroupLabels,
|
type ChatGroupLabels,
|
||||||
} from "@/lib/chat-groups";
|
} from "@/lib/chat-groups";
|
||||||
|
import { writeDraggedSession } from "@/lib/session-drag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||||
|
|
||||||
@ -276,10 +277,19 @@ export const ChatList = memo(function ChatList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(s.key)}
|
onClick={() => onSelect(s.key)}
|
||||||
|
draggable={!active}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
if (active) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDraggedSession(event.dataTransfer, s.key);
|
||||||
|
}}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
title={tooltipTitle}
|
title={tooltipTitle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 overflow-hidden text-left",
|
"min-w-0 flex-1 overflow-hidden text-left",
|
||||||
|
!active && "cursor-grab active:cursor-grabbing",
|
||||||
compact ? "py-1" : "py-1.5",
|
compact ? "py-1" : "py-1.5",
|
||||||
projectMode && "pl-7",
|
projectMode && "pl-7",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -105,6 +105,10 @@ import {
|
|||||||
isSideChannelLifecycle,
|
isSideChannelLifecycle,
|
||||||
slashCommandLifecycle,
|
slashCommandLifecycle,
|
||||||
} from "@/lib/slash-command";
|
} from "@/lib/slash-command";
|
||||||
|
import {
|
||||||
|
hasDraggedSession,
|
||||||
|
readDraggedSession,
|
||||||
|
} from "@/lib/session-drag";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@ -1606,10 +1610,13 @@ export function ThreadComposer({
|
|||||||
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
|
[isStreaming, onStop, recentSlashCommands, resizeTextarea, skillQuery, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chooseMentionCandidate = useCallback(
|
const insertMentionCandidate = useCallback(
|
||||||
(candidate: MentionCandidate) => {
|
(candidate: MentionCandidate, start: number, end: number) => {
|
||||||
if (!cliAppMention) return;
|
|
||||||
if (candidate.kind === "session") {
|
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();
|
const name = candidate.name.toLowerCase();
|
||||||
setSelectedSessionMentions([
|
setSelectedSessionMentions([
|
||||||
...activeSessionMentions.filter((mention) => (
|
...activeSessionMentions.filter((mention) => (
|
||||||
@ -1619,10 +1626,13 @@ export function ThreadComposer({
|
|||||||
candidate.mention,
|
candidate.mention,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
const suffix = value.slice(cliAppMention.end);
|
const prefix = value.slice(0, start);
|
||||||
const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`;
|
const suffix = value.slice(end);
|
||||||
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
|
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : "";
|
||||||
const nextCursor = cliAppMention.start + mention.length;
|
const trailingSpace = /^\s/.test(suffix) ? "" : " ";
|
||||||
|
const mention = `${leadingSpace}@${candidate.name}${trailingSpace}`;
|
||||||
|
const next = `${prefix}${mention}${suffix}`;
|
||||||
|
const nextCursor = prefix.length + mention.length;
|
||||||
setValue(next);
|
setValue(next);
|
||||||
setCursorPosition(nextCursor);
|
setCursorPosition(nextCursor);
|
||||||
setCliAppMenuDismissed(true);
|
setCliAppMenuDismissed(true);
|
||||||
@ -1636,9 +1646,40 @@ export function ThreadComposer({
|
|||||||
el.setSelectionRange(nextCursor, nextCursor);
|
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) => {
|
const clearComposerText = useCallback((restoreFocus = true) => {
|
||||||
setValue("");
|
setValue("");
|
||||||
setSelectedSessionMentions([]);
|
setSelectedSessionMentions([]);
|
||||||
@ -2068,9 +2109,18 @@ export function ThreadComposer({
|
|||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragOver={onDragOver}
|
onDragOver={(event) => {
|
||||||
|
if (hasDraggedSession(event.dataTransfer)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
} else {
|
||||||
|
onDragOver(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragLeave={onDragLeave}
|
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")}
|
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||||
>
|
>
|
||||||
{showSlashMenu ? (
|
{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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import { ChatList } from "@/components/ChatList";
|
||||||
|
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||||
@ -47,6 +48,39 @@ describe("ChatList", () => {
|
|||||||
vi.unstubAllGlobals();
|
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", () => {
|
it("orders chats by latest session activity by default", () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
session({
|
session({
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||||
|
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||||
import type { ChatSummary, CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
import type { ChatSummary, CliAppInfo, McpPresetInfo, SlashCommand } from "@/lib/types";
|
||||||
|
|
||||||
vi.mock("@/lib/imageEncode", () => ({
|
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", () => {
|
it("disambiguates duplicate and capability-colliding session names", () => {
|
||||||
render(
|
render(
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user