mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
refactor(session): tighten cross-session access
This commit is contained in:
@@ -1248,20 +1248,15 @@ export function ThreadComposer({
|
||||
),
|
||||
[cliApps, mcpPresets, sessions],
|
||||
);
|
||||
const sessionMentionsForText = useMemo(() => {
|
||||
const selectedNames = new Set(
|
||||
selectedSessionMentions.map((mention) => mention.name.toLowerCase()),
|
||||
);
|
||||
return [
|
||||
...selectedSessionMentions,
|
||||
...availableSessionMentions.filter(
|
||||
(mention) => !selectedNames.has(mention.name.toLowerCase()),
|
||||
),
|
||||
];
|
||||
}, [availableSessionMentions, selectedSessionMentions]);
|
||||
const filteredMentionCandidates = useMemo<MentionCandidate[]>(() => {
|
||||
if (!cliAppMention) return [];
|
||||
const sessionCandidates: MentionCandidate[] = availableSessionMentions
|
||||
.filter((mention) => (
|
||||
selectedSessionMentions.length < SESSION_MENTIONS_LIMIT
|
||||
|| selectedSessionMentions.some(
|
||||
(selected) => selected.session_key === mention.session_key,
|
||||
)
|
||||
))
|
||||
.filter((mention) => [
|
||||
mention.name,
|
||||
mention.title,
|
||||
@@ -1306,7 +1301,7 @@ export function ThreadComposer({
|
||||
remaining -= extra;
|
||||
}
|
||||
return groups.flatMap((group, index) => group.slice(0, limits[index]));
|
||||
}, [availableSessionMentions, cliAppMention, cliApps, mcpPresets]);
|
||||
}, [availableSessionMentions, cliAppMention, cliApps, mcpPresets, selectedSessionMentions]);
|
||||
|
||||
const showCliAppMenu = filteredMentionCandidates.length > 0;
|
||||
const showAnyPalette = showSlashMenu || showCliAppMenu;
|
||||
@@ -1315,9 +1310,9 @@ export function ThreadComposer({
|
||||
value,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
sessionMentionsForText,
|
||||
selectedSessionMentions,
|
||||
),
|
||||
[cliApps, mcpPresets, sessionMentionsForText, value],
|
||||
[cliApps, mcpPresets, selectedSessionMentions, value],
|
||||
);
|
||||
const hasMentionDecorations = mentionSegments.some(
|
||||
(segment) => segment.kind !== "text",
|
||||
@@ -1344,7 +1339,7 @@ export function ThreadComposer({
|
||||
if (segment.kind !== "session" || seen.has(segment.mention.session_key)) return [];
|
||||
seen.add(segment.mention.session_key);
|
||||
return [segment.mention];
|
||||
});
|
||||
}).slice(0, SESSION_MENTIONS_LIMIT);
|
||||
}, [mentionSegments]);
|
||||
useEffect(() => {
|
||||
setSelectedSessionMentions((current) => {
|
||||
@@ -2755,7 +2750,7 @@ function CliAppMentionPalette({
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
|
||||
{candidate.kind === "session" ? typeLabel : `@${name}`}
|
||||
@{name}
|
||||
</span>
|
||||
</span>
|
||||
{candidate.kind !== "session" ? (
|
||||
|
||||
@@ -604,8 +604,14 @@ export function ThreadShell({
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const mentionSessions = useMemo(
|
||||
() => sessions.filter((candidate) => candidate.key !== historyKey),
|
||||
[historyKey, sessions],
|
||||
() => sessions.filter((candidate) => (
|
||||
candidate.key !== historyKey
|
||||
&& (
|
||||
workspaceScope?.access_mode !== "restricted"
|
||||
|| candidate.workspaceScope?.project_path === workspaceScope.project_path
|
||||
)
|
||||
)),
|
||||
[historyKey, sessions, workspaceScope],
|
||||
);
|
||||
const {
|
||||
messages: historical,
|
||||
|
||||
@@ -1579,6 +1579,95 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a session only after an explicit palette selection", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={[{
|
||||
key: "websocket:pricing",
|
||||
channel: "websocket",
|
||||
chatId: "pricing",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: "收费设计",
|
||||
preview: "讨论云存储",
|
||||
}]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: "普通文字 @收费设计", selectionStart: 10 },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("composer-session-mention-收费设计")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("普通文字 @收费设计", undefined, undefined);
|
||||
});
|
||||
|
||||
it("shows stable aliases for sessions with the same title", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Type your message..."
|
||||
sessions={["a", "b"].map((chatId) => ({
|
||||
key: `websocket:${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: "Plan",
|
||||
preview: "",
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
const options = screen.getAllByRole("option", { name: /Plan @Plan/i });
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
expect.stringContaining("@Plan"),
|
||||
expect.stringContaining("@Plan-chat"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the composer and wire payload on the same eight-session limit", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
placeholder="Type your message..."
|
||||
sessions={Array.from({ length: 9 }, (_, index) => ({
|
||||
key: `websocket:topic-${index}`,
|
||||
channel: "websocket",
|
||||
chatId: `topic-${index}`,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: `Topic${index}`,
|
||||
preview: "",
|
||||
}))}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input") as HTMLTextAreaElement;
|
||||
for (let index = 0; index < 9; index += 1) {
|
||||
const value = `${input.value}${input.value ? " " : ""}@Topic${index}`;
|
||||
fireEvent.change(input, { target: { value, selectionStart: value.length } });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
}
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
const options = onSend.mock.calls[0]?.[2];
|
||||
expect(options.sessionMentions).toHaveLength(8);
|
||||
expect(options.sessionMentions.map((mention: { session_key: string }) => (
|
||||
mention.session_key
|
||||
))).not.toContain("websocket:topic-8");
|
||||
});
|
||||
|
||||
it("keeps a selected session stable across refreshes and queued guidance", () => {
|
||||
const onSend = vi.fn();
|
||||
const target = {
|
||||
|
||||
@@ -3767,4 +3767,42 @@ describe("ThreadShell", () => {
|
||||
"@obsidian-agent-cli",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers only same-project sessions in restricted mode", async () => {
|
||||
const client = makeClient();
|
||||
const currentScope = {
|
||||
project_path: "/projects/current",
|
||||
access_mode: "restricted" as const,
|
||||
};
|
||||
const sameProject = {
|
||||
...session("same-project"),
|
||||
title: "Same project",
|
||||
workspaceScope: currentScope,
|
||||
};
|
||||
const otherProject = {
|
||||
...session("other-project"),
|
||||
title: "Other project",
|
||||
workspaceScope: {
|
||||
project_path: "/projects/other",
|
||||
access_mode: "restricted" as const,
|
||||
},
|
||||
};
|
||||
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("current")}
|
||||
sessions={[sameProject, otherProject]}
|
||||
title="Current"
|
||||
onToggleSidebar={() => {}}
|
||||
workspaceScope={currentScope}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
expect(screen.getByRole("option", { name: /Same project/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Other project/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user