126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
|
|
import { describe, expect, it, vi } from "vitest";
|
||
|
|
import {
|
||
|
|
createComposerFocusMemory,
|
||
|
|
createChatScrollMemory,
|
||
|
|
} from "./workspace-interaction";
|
||
|
|
import { changeAIMode, parseAIMode } from "./workspace-mode";
|
||
|
|
import { submitComposer } from "./composer-events";
|
||
|
|
import { agentBuilderStub } from "./agent-builder";
|
||
|
|
|
||
|
|
describe("mode navigation events", () => {
|
||
|
|
it("pushes canonical URLs, preserves hash/query, and reads history navigation", () => {
|
||
|
|
const history = ["https://example.test/acme/ai?filter=mine#library"];
|
||
|
|
let index = 0;
|
||
|
|
const navigate = (mode: "chat" | "agents") => {
|
||
|
|
const url = new URL(history[index]);
|
||
|
|
changeAIMode(url, mode, (href) => {
|
||
|
|
history.splice(++index);
|
||
|
|
history.push(new URL(href, url).href);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
navigate("chat");
|
||
|
|
expect(history).toHaveLength(1);
|
||
|
|
navigate("agents");
|
||
|
|
expect(history[index]).toBe(
|
||
|
|
"https://example.test/acme/ai?filter=mine&tab=agents#library",
|
||
|
|
);
|
||
|
|
navigate("agents");
|
||
|
|
expect(history).toHaveLength(2);
|
||
|
|
navigate("chat");
|
||
|
|
expect(history[index]).toBe(
|
||
|
|
"https://example.test/acme/ai?filter=mine&tab=chat#library",
|
||
|
|
);
|
||
|
|
index--;
|
||
|
|
expect(parseAIMode(new URL(history[index]).searchParams.get("tab"))).toBe(
|
||
|
|
"agents",
|
||
|
|
);
|
||
|
|
index++;
|
||
|
|
expect(parseAIMode(new URL(history[index]).searchParams.get("tab"))).toBe(
|
||
|
|
"chat",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("composer focus memory", () => {
|
||
|
|
it("restores the previously focused composer on history return, not initial entry", () => {
|
||
|
|
const memory = createComposerFocusMemory();
|
||
|
|
expect(memory.activate("chat", false)).toBe(false);
|
||
|
|
memory.record("chat", true);
|
||
|
|
expect(memory.activate("agents", false)).toBe(false);
|
||
|
|
expect(memory.activate("chat", false)).toBe(true);
|
||
|
|
});
|
||
|
|
it("never steals keyboard tab focus and forgets focus moved elsewhere in a panel", () => {
|
||
|
|
const memory = createComposerFocusMemory();
|
||
|
|
memory.record("chat", true);
|
||
|
|
expect(memory.activate("agents", true)).toBe(false);
|
||
|
|
expect(memory.activate("chat", true)).toBe(false);
|
||
|
|
memory.record("chat", false);
|
||
|
|
memory.activate("agents", false);
|
||
|
|
expect(memory.activate("chat", false)).toBe(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Chat scroll memory", () => {
|
||
|
|
it("follows streamed content only when already near the bottom", () => {
|
||
|
|
const memory = createChatScrollMemory();
|
||
|
|
const container = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 };
|
||
|
|
memory.update(container, true);
|
||
|
|
container.scrollTop = 400;
|
||
|
|
memory.record(container, true);
|
||
|
|
container.scrollHeight = 1200;
|
||
|
|
memory.update(container, true);
|
||
|
|
expect(container.scrollTop).toBe(400);
|
||
|
|
container.scrollTop = 980;
|
||
|
|
memory.record(container, true);
|
||
|
|
container.scrollHeight = 1400;
|
||
|
|
memory.update(container, true);
|
||
|
|
expect(container.scrollTop).toBe(1200);
|
||
|
|
});
|
||
|
|
it("ignores hidden scroll events and restores saved position before following new updates", () => {
|
||
|
|
const memory = createChatScrollMemory();
|
||
|
|
const container = { scrollTop: 0, scrollHeight: 1000, clientHeight: 200 };
|
||
|
|
memory.update(container, true);
|
||
|
|
container.scrollTop = 300;
|
||
|
|
memory.record(container, true);
|
||
|
|
memory.update(container, false);
|
||
|
|
container.scrollTop = 0;
|
||
|
|
memory.record(container, false);
|
||
|
|
container.scrollHeight = 1500;
|
||
|
|
memory.update(container, false);
|
||
|
|
memory.update(container, true);
|
||
|
|
expect(container.scrollTop).toBe(300);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("composer submission events", () => {
|
||
|
|
it("submits only through the supplied mode adapter and restores focus", async () => {
|
||
|
|
const chat = vi.fn();
|
||
|
|
const focus = vi.fn();
|
||
|
|
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||
|
|
const agents = vi.fn(() =>
|
||
|
|
agentBuilderStub({ workspaceSlug: "acme", prompt: "Triage tasks" }),
|
||
|
|
);
|
||
|
|
const event = { preventDefault: vi.fn() };
|
||
|
|
try {
|
||
|
|
submitComposer(event, "Triage tasks", false, agents, focus);
|
||
|
|
expect((await agents.mock.results[0].value).status).toBe("unavailable");
|
||
|
|
expect(chat).not.toHaveBeenCalled();
|
||
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||
|
|
expect(focus).toHaveBeenCalledOnce();
|
||
|
|
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||
|
|
} finally {
|
||
|
|
fetchSpy.mockRestore();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
it.each([
|
||
|
|
[" ", false],
|
||
|
|
["Draft", true],
|
||
|
|
])("blocks blank/busy submissions", (input, busy) => {
|
||
|
|
const send = vi.fn();
|
||
|
|
const focus = vi.fn();
|
||
|
|
submitComposer({ preventDefault: vi.fn() }, input, busy, send, focus);
|
||
|
|
expect(send).not.toHaveBeenCalled();
|
||
|
|
expect(focus).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|