feat(webui): drag sessions into composer mentions

This commit is contained in:
Xubin Ren 2026-08-06 10:54:51 +08:00
parent 5c4c2cb819
commit 2c8e63446f
5 changed files with 164 additions and 10 deletions

View File

@ -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",
)} )}

View File

@ -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 ? (

View 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);
}

View File

@ -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({

View File

@ -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