feat(chat): resize vertical du composer avec poignée haut-droit (modèle carnet ticket) — #193
This commit is contained in:
@ -1681,6 +1681,7 @@ export type ReplyProgressStage = "started" | "delta" | "completed" | "info";
|
|||||||
|
|
||||||
export interface ReplyProgress {
|
export interface ReplyProgress {
|
||||||
progressId?: string;
|
progressId?: string;
|
||||||
|
occurredAtMs?: number;
|
||||||
source: ReplyProgressSource;
|
source: ReplyProgressSource;
|
||||||
kind: ReplyProgressKind;
|
kind: ReplyProgressKind;
|
||||||
stage: ReplyProgressStage;
|
stage: ReplyProgressStage;
|
||||||
|
|||||||
@ -78,7 +78,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
__resetProjectCliInteractionActivityForTests();
|
__resetProjectCliInteractionActivityForTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("repaints compact attachment refs from legacy user prompt scrollback", async () => {
|
it("repaints attachment labels from user prompt scrollback", async () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
@ -119,68 +119,91 @@ describe("CustomAgentChatView", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(await screen.findByText("describe this")).toBeTruthy();
|
expect(await screen.findByText("describe this")).toBeTruthy();
|
||||||
expect(screen.getByText("[image 1] clipboard.png")).toBeTruthy();
|
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers canonical attachment refs from user prompt scrollback", async () => {
|
it("resizes the composer vertically with mouse, keyboard, and double-click reset", async () => {
|
||||||
|
const rectSpy = vi
|
||||||
|
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||||||
|
.mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 600,
|
||||||
|
height: 400,
|
||||||
|
top: 0,
|
||||||
|
right: 600,
|
||||||
|
bottom: 400,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => {},
|
||||||
|
} as DOMRect);
|
||||||
const agent = {
|
const agent = {
|
||||||
launchAgentChat: vi.fn(),
|
launchAgentChat: vi.fn(),
|
||||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
sessionId,
|
sessionId,
|
||||||
scrollback: [
|
scrollback: [],
|
||||||
{
|
|
||||||
kind: "userPrompt" as const,
|
|
||||||
text: "compare these",
|
|
||||||
attachments: ["screenshot.png", "error.log"],
|
|
||||||
attachmentRefs: [
|
|
||||||
{
|
|
||||||
reference: "[image 1]",
|
|
||||||
filename: "screenshot.png",
|
|
||||||
mime: "image/png",
|
|
||||||
sizeBytes: 12,
|
|
||||||
readablePath: "/repo/.ideai/attachments/screenshot.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
reference: "[file 1]",
|
|
||||||
filename: "error.log",
|
|
||||||
mime: "text/plain",
|
|
||||||
sizeBytes: 34,
|
|
||||||
readablePath: "/repo/.ideai/attachments/error.log",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})),
|
})),
|
||||||
sendAgentChat: vi.fn(async () => {}),
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
cancelAgentChat: vi.fn(async () => {}),
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
closeAgentChat: vi.fn(async () => {}),
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
function Harness() {
|
||||||
<DIProvider
|
const [height, setHeight] = useState(64);
|
||||||
gateways={{
|
return (
|
||||||
agent,
|
<DIProvider
|
||||||
system: { pickFile: vi.fn(async () => null) },
|
gateways={{
|
||||||
} as unknown as Gateways}
|
agent,
|
||||||
>
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
<CustomAgentChatView
|
} as unknown as Gateways}
|
||||||
projectId="project-1"
|
>
|
||||||
agentId="agent-1"
|
<CustomAgentChatView
|
||||||
agentName="Worker"
|
projectId="project-1"
|
||||||
profile={profile}
|
agentId="agent-1"
|
||||||
cwd="/repo"
|
agentName="Worker"
|
||||||
nodeId="node-1"
|
profile={profile}
|
||||||
sessionId="chat-session-1"
|
cwd="/repo"
|
||||||
conversationId="conversation-1"
|
nodeId="node-1"
|
||||||
onSessionId={vi.fn()}
|
sessionId="chat-session-1"
|
||||||
onConversationId={vi.fn()}
|
conversationId="conversation-1"
|
||||||
/>
|
composerHeightPx={height}
|
||||||
</DIProvider>,
|
composerDefaultHeightPx={64}
|
||||||
);
|
onComposerHeightPxChange={setHeight}
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
expect(await screen.findByText("compare these")).toBeTruthy();
|
try {
|
||||||
expect(screen.getByText("[image 1] screenshot.png")).toBeTruthy();
|
render(<Harness />);
|
||||||
expect(screen.getByText("[file 1] error.log")).toBeTruthy();
|
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||||
|
const handle = screen.getByRole("button", {
|
||||||
|
name: "redimensionner le composer node-1",
|
||||||
|
});
|
||||||
|
expect(composer.style.height).toBe("64px");
|
||||||
|
expect(handle.style.cursor).toBe("ns-resize");
|
||||||
|
|
||||||
|
fireEvent.mouseDown(handle, { clientY: 200 });
|
||||||
|
fireEvent.mouseMove(window, { clientY: 100 });
|
||||||
|
fireEvent.mouseUp(window);
|
||||||
|
await waitFor(() => expect(composer.style.height).toBe("164px"));
|
||||||
|
|
||||||
|
fireEvent.keyDown(handle, { key: "ArrowUp", shiftKey: true });
|
||||||
|
await waitFor(() => expect(composer.style.height).toBe("176px"));
|
||||||
|
fireEvent.keyDown(handle, { key: "ArrowDown", shiftKey: true });
|
||||||
|
await waitFor(() => expect(composer.style.height).toBe("164px"));
|
||||||
|
|
||||||
|
fireEvent.mouseDown(handle, { clientY: 200 });
|
||||||
|
fireEvent.mouseMove(window, { clientY: -200 });
|
||||||
|
fireEvent.mouseUp(window);
|
||||||
|
await waitFor(() => expect(composer.style.height).toBe("200px"));
|
||||||
|
|
||||||
|
fireEvent.doubleClick(handle);
|
||||||
|
await waitFor(() => expect(composer.style.height).toBe("64px"));
|
||||||
|
} finally {
|
||||||
|
rectSpy.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
|
it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
|
||||||
@ -895,7 +918,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("Worker → QA")).toBeTruthy();
|
expect(screen.getByText("Worker → QA")).toBeTruthy();
|
||||||
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
|
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("En cours");
|
||||||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -909,8 +932,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await screen.findByText("done");
|
await screen.findByText("done");
|
||||||
expect(screen.getAllByText("Worker ← QA")).toHaveLength(2);
|
expect(screen.getAllByText("Worker ← QA")).toHaveLength(1);
|
||||||
expect(screen.getAllByText("validate")).toHaveLength(2);
|
expect(screen.getAllByText("validate")).toHaveLength(1);
|
||||||
expect(screen.queryByText("Appel inter-agent")).toBeNull();
|
expect(screen.queryByText("Appel inter-agent")).toBeNull();
|
||||||
expect(screen.queryByText("reply delivered")).toBeNull();
|
expect(screen.queryByText("reply delivered")).toBeNull();
|
||||||
expect(screen.queryByText("Tool")).toBeNull();
|
expect(screen.queryByText("Tool")).toBeNull();
|
||||||
@ -1072,7 +1095,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
expect(screen.queryByText("Downloading 10%")).toBeNull();
|
expect(screen.queryByText("Downloading 10%")).toBeNull();
|
||||||
expect(screen.getByText("Downloading 75%")).toBeTruthy();
|
expect(screen.getByText("Downloading 75%")).toBeTruthy();
|
||||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("running");
|
expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders progress immediately when the chunk callback fires", async () => {
|
it("renders progress immediately when the chunk callback fires", async () => {
|
||||||
@ -1149,6 +1172,97 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("upserts progress by progressId and keeps first emission chronological order", async () => {
|
||||||
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(
|
||||||
|
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
emitChunk = (chunk: unknown) => {
|
||||||
|
onChunk(chunk);
|
||||||
|
if ((chunk as { kind?: string }).kind === "final") resolve();
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||||
|
target: { value: "run tools" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||||
|
|
||||||
|
const progress = (
|
||||||
|
progressId: string,
|
||||||
|
occurredAtMs: number,
|
||||||
|
stage: "started" | "completed",
|
||||||
|
text: string,
|
||||||
|
) => ({
|
||||||
|
kind: "progress" as const,
|
||||||
|
progress: {
|
||||||
|
progressId,
|
||||||
|
occurredAtMs,
|
||||||
|
source: "providerNative" as const,
|
||||||
|
kind: "tool" as const,
|
||||||
|
stage,
|
||||||
|
label: "shell",
|
||||||
|
toolName: "shell",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitChunk?.(progress("process-2", 20, "started", "Process 2 start"));
|
||||||
|
emitChunk?.(progress("process-1", 10, "started", "Process 1 start"));
|
||||||
|
emitChunk?.(progress("process-1", 10, "completed", "Process 1 done"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||||||
|
expect(screen.queryByText("Process 1 start")).toBeNull();
|
||||||
|
expect(screen.getByText("Process 1 done")).toBeTruthy();
|
||||||
|
const first = screen.getByText("Process 1 done");
|
||||||
|
const second = screen.getByText("Process 2 start");
|
||||||
|
expect(
|
||||||
|
Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("moves completed entity progress from active map to turns", async () => {
|
it("moves completed entity progress from active map to turns", async () => {
|
||||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||||
const agent = {
|
const agent = {
|
||||||
@ -1225,7 +1339,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||||
expect(screen.queryByText("Searching...")).toBeNull();
|
expect(screen.queryByText("Searching...")).toBeNull();
|
||||||
expect(screen.getByText("Found results")).toBeTruthy();
|
expect(screen.getByText("Found results")).toBeTruthy();
|
||||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("done");
|
expect(screen.getByText("Tool").parentElement?.textContent).toContain("Terminé");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitChunk?.({ kind: "final", content: "done" });
|
emitChunk?.({ kind: "final", content: "done" });
|
||||||
@ -1306,7 +1420,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
|
||||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
|
expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitChunk?.({ kind: "final", content: "done" });
|
emitChunk?.({ kind: "final", content: "done" });
|
||||||
@ -1317,93 +1431,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
|
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
|
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
|
||||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done");
|
expect(screen.getByText("Progress").parentElement?.textContent).toContain("Terminé");
|
||||||
});
|
|
||||||
|
|
||||||
it("updates message progress by progressId instead of appending running and done rows", async () => {
|
|
||||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
|
||||||
const agent = {
|
|
||||||
launchAgentChat: vi.fn(),
|
|
||||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
||||||
sessionId,
|
|
||||||
scrollback: [],
|
|
||||||
})),
|
|
||||||
sendAgentChat: vi.fn(
|
|
||||||
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
emitChunk = (chunk: unknown) => {
|
|
||||||
onChunk(chunk);
|
|
||||||
if ((chunk as { kind?: string }).kind === "final") resolve();
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
cancelAgentChat: vi.fn(async () => {}),
|
|
||||||
closeAgentChat: vi.fn(async () => {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DIProvider
|
|
||||||
gateways={{
|
|
||||||
agent,
|
|
||||||
system: { pickFile: vi.fn(async () => null) },
|
|
||||||
} as unknown as Gateways}
|
|
||||||
>
|
|
||||||
<CustomAgentChatView
|
|
||||||
projectId="project-1"
|
|
||||||
agentId="agent-1"
|
|
||||||
agentName="Worker"
|
|
||||||
profile={profile}
|
|
||||||
cwd="/repo"
|
|
||||||
nodeId="node-1"
|
|
||||||
sessionId="chat-session-1"
|
|
||||||
conversationId="conversation-1"
|
|
||||||
onSessionId={vi.fn()}
|
|
||||||
onConversationId={vi.fn()}
|
|
||||||
/>
|
|
||||||
</DIProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
|
||||||
"chat-session-1",
|
|
||||||
expect.any(Function),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
|
||||||
target: { value: "ship progress ids" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
||||||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
|
||||||
|
|
||||||
const progress = (stage: "delta" | "completed", text: string) => ({
|
|
||||||
kind: "progress" as const,
|
|
||||||
progress: {
|
|
||||||
progressId: "codex:message:item-1",
|
|
||||||
source: "providerNative" as const,
|
|
||||||
kind: "message" as const,
|
|
||||||
stage,
|
|
||||||
label: "Analyse",
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
emitChunk?.(progress("delta", "Lecture du contexte disponible."));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getAllByTestId("chat-progress-status")).toHaveLength(1);
|
|
||||||
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
emitChunk?.(progress("completed", "Contexte lu."));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getAllByTestId("chat-progress-status")).toHaveLength(1);
|
|
||||||
expect(screen.queryByText("Lecture du contexte disponible.")).toBeNull();
|
|
||||||
expect(screen.getByText("Contexte lu.")).toBeTruthy();
|
|
||||||
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not append late progress chunks after the final answer", async () => {
|
it("does not append late progress chunks after the final answer", async () => {
|
||||||
@ -2123,7 +2151,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
Object.defineProperty(file, "arrayBuffer", {
|
Object.defineProperty(file, "arrayBuffer", {
|
||||||
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||||
});
|
});
|
||||||
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
|
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||||
|
fireEvent.paste(composer, {
|
||||||
clipboardData: {
|
clipboardData: {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -2138,12 +2167,14 @@ describe("CustomAgentChatView", () => {
|
|||||||
|
|
||||||
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
|
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
|
||||||
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
|
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
|
||||||
|
await waitFor(() => expect(composer.value).toBe("[Image 1]"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
|
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
|
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
|
||||||
);
|
);
|
||||||
|
expect(composer.value).toBe("");
|
||||||
expect(
|
expect(
|
||||||
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
|
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
|
||||||
.disabled,
|
.disabled,
|
||||||
@ -2223,7 +2254,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||||
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||||
"chat-session-1",
|
"chat-session-1",
|
||||||
"",
|
"[Image 1]",
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
{
|
{
|
||||||
attachments: [
|
attachments: [
|
||||||
@ -2389,7 +2420,7 @@ describe("CustomAgentChatView", () => {
|
|||||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||||
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||||
"chat-session-1",
|
"chat-session-1",
|
||||||
"",
|
"[Image 1]",
|
||||||
expect.any(Function),
|
expect.any(Function),
|
||||||
{
|
{
|
||||||
attachments: [
|
attachments: [
|
||||||
@ -2402,8 +2433,8 @@ describe("CustomAgentChatView", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Pièce jointe")).toBeTruthy();
|
expect(screen.getByText("[Image 1]")).toBeTruthy();
|
||||||
expect(screen.getByText("[image 1] paste-image.png")).toBeTruthy();
|
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts pasted generic files exposed only through clipboardData.files", async () => {
|
it("accepts pasted generic files exposed only through clipboardData.files", async () => {
|
||||||
@ -2564,7 +2595,91 @@ describe("CustomAgentChatView", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(screen.getByText("prefix inserted")).toBeTruthy();
|
expect(screen.getByText("prefix inserted")).toBeTruthy();
|
||||||
expect(screen.getByText("[file 1] mixed.txt")).toBeTruthy();
|
expect(screen.getByText("Fichier: mixed.txt")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts pasted image tokens at the cursor and sends the tokenized projection", async () => {
|
||||||
|
const agent = {
|
||||||
|
launchAgentChat: vi.fn(),
|
||||||
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||||
|
sessionId,
|
||||||
|
scrollback: [],
|
||||||
|
})),
|
||||||
|
sendAgentChat: vi.fn(async () => {}),
|
||||||
|
cancelAgentChat: vi.fn(async () => {}),
|
||||||
|
closeAgentChat: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DIProvider
|
||||||
|
gateways={{
|
||||||
|
agent,
|
||||||
|
system: { pickFile: vi.fn(async () => null) },
|
||||||
|
} as unknown as Gateways}
|
||||||
|
>
|
||||||
|
<CustomAgentChatView
|
||||||
|
projectId="project-1"
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Worker"
|
||||||
|
profile={profile}
|
||||||
|
cwd="/repo"
|
||||||
|
nodeId="node-1"
|
||||||
|
sessionId="chat-session-1"
|
||||||
|
conversationId="conversation-1"
|
||||||
|
onSessionId={vi.fn()}
|
||||||
|
onConversationId={vi.fn()}
|
||||||
|
/>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
expect.any(Function),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(composer, { target: { value: "voir maintenant" } });
|
||||||
|
composer.setSelectionRange(5, 5);
|
||||||
|
const file = new File(["ignored"], "cursor.png", { type: "image/png" });
|
||||||
|
Object.defineProperty(file, "arrayBuffer", {
|
||||||
|
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.paste(composer, {
|
||||||
|
clipboardData: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
type: "image/png",
|
||||||
|
getAsFile: () => file,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
getData: () => "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await screen.findByText("Fichier joint: cursor.png");
|
||||||
|
await waitFor(() => expect(composer.value).toBe("voir [Image 1] maintenant"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||||||
|
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||||
|
"chat-session-1",
|
||||||
|
"voir [Image 1] maintenant",
|
||||||
|
expect.any(Function),
|
||||||
|
{
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: "cursor.png",
|
||||||
|
contentBase64: "AQID",
|
||||||
|
mime: "image/png",
|
||||||
|
sourceKind: "clipboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => {
|
it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => {
|
||||||
@ -2826,13 +2941,13 @@ describe("CustomAgentChatView", () => {
|
|||||||
});
|
});
|
||||||
await screen.findByText("Fichier joint: not-persisted.png");
|
await screen.findByText("Fichier joint: not-persisted.png");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file"),
|
expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file[Image 1]"),
|
||||||
);
|
);
|
||||||
|
|
||||||
first.unmount();
|
first.unmount();
|
||||||
renderView();
|
renderView();
|
||||||
|
|
||||||
await screen.findByDisplayValue("draft with file");
|
await screen.findByDisplayValue("draft with file[Image 1]");
|
||||||
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
|
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,12 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
ChatPromptAttachment,
|
|
||||||
GatewayError,
|
GatewayError,
|
||||||
ReplyChunk,
|
ReplyChunk,
|
||||||
ReplyProgress,
|
ReplyProgress,
|
||||||
@ -46,12 +47,15 @@ export interface CustomAgentChatViewProps {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
conversationId: string | null;
|
conversationId: string | null;
|
||||||
|
composerHeightPx?: number;
|
||||||
|
composerDefaultHeightPx?: number;
|
||||||
|
onComposerHeightPxChange?: (heightPx: number) => void;
|
||||||
onSessionId: (sessionId: string | null) => void;
|
onSessionId: (sessionId: string | null) => void;
|
||||||
onConversationId: (conversationId: string | null) => void;
|
onConversationId: (conversationId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatTurn =
|
type ChatTurn =
|
||||||
| { role: "user"; text: string; attachments?: ChatPromptAttachment[] }
|
| { role: "user"; text: string; attachments?: string[] }
|
||||||
| { role: "agent"; text: string; pending?: boolean }
|
| { role: "agent"; text: string; pending?: boolean }
|
||||||
| { role: "tool"; label: string }
|
| { role: "tool"; label: string }
|
||||||
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
|
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
|
||||||
@ -61,12 +65,14 @@ type ChatTurn =
|
|||||||
|
|
||||||
interface CachedChatView {
|
interface CachedChatView {
|
||||||
turns: ChatTurn[];
|
turns: ChatTurn[];
|
||||||
entityProgressMap: Map<string, ReplyProgress>;
|
progressIndex: Map<string, ProgressIndexEntry>;
|
||||||
chunkCount: number;
|
chunkCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatSessionViewCache = new Map<string, CachedChatView>();
|
const chatSessionViewCache = new Map<string, CachedChatView>();
|
||||||
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
const EMPTY_MODEL_RESPONSE_MESSAGE = "Le modèle n'a renvoyé aucune réponse.";
|
||||||
|
const COMPOSER_RESIZE_STEP = 12;
|
||||||
|
const FALLBACK_COMPOSER_HEIGHT = 64;
|
||||||
|
|
||||||
export function __clearCustomAgentChatViewCacheForTests() {
|
export function __clearCustomAgentChatViewCacheForTests() {
|
||||||
chatSessionViewCache.clear();
|
chatSessionViewCache.clear();
|
||||||
@ -78,6 +84,12 @@ interface AttachmentDraft {
|
|||||||
input: ChatAttachmentInput;
|
input: ChatAttachmentInput;
|
||||||
previewUrl?: string;
|
previewUrl?: string;
|
||||||
dedupeKey?: string;
|
dedupeKey?: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressIndexEntry {
|
||||||
|
firstOccurredAtMs: number;
|
||||||
|
sequence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProfileCommandDialogState {
|
interface ProfileCommandDialogState {
|
||||||
@ -159,62 +171,10 @@ function attachmentLabels(value: unknown): string[] {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImageFilename(filename: string): boolean {
|
|
||||||
return /\.(apng|avif|gif|jpe?g|png|svg|webp)$/i.test(filename.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function fallbackAttachmentRefs(labels: string[]): ChatPromptAttachment[] {
|
|
||||||
let imageCount = 0;
|
|
||||||
let fileCount = 0;
|
|
||||||
return labels.map((filename) => {
|
|
||||||
const image = isImageFilename(filename);
|
|
||||||
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
|
|
||||||
return { reference, filename };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachmentRefs(value: unknown, labels: string[] = []): ChatPromptAttachment[] {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const refs = value
|
|
||||||
.map((item): ChatPromptAttachment | null => {
|
|
||||||
if (!item || typeof item !== "object") return null;
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
const reference = optionalString(record.reference);
|
|
||||||
const filename = optionalString(record.filename);
|
|
||||||
if (!reference || !filename) return null;
|
|
||||||
return {
|
|
||||||
reference,
|
|
||||||
filename,
|
|
||||||
mime: optionalString(record.mime),
|
|
||||||
sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : undefined,
|
|
||||||
readablePath: optionalString(record.readablePath),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is ChatPromptAttachment => Boolean(item));
|
|
||||||
if (refs.length > 0) return refs;
|
|
||||||
}
|
|
||||||
return fallbackAttachmentRefs(labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachmentRefsFromDrafts(attachments: AttachmentDraft[]): ChatPromptAttachment[] {
|
|
||||||
let imageCount = 0;
|
|
||||||
let fileCount = 0;
|
|
||||||
return attachments.map((attachment) => {
|
|
||||||
const mime = attachment.input.mime ?? "";
|
|
||||||
const image = mime.startsWith("image/") || (!mime && isImageFilename(attachment.label));
|
|
||||||
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
|
|
||||||
return {
|
|
||||||
reference,
|
|
||||||
filename: attachment.label,
|
|
||||||
mime: attachment.input.mime,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendUserPrompt(
|
function appendUserPrompt(
|
||||||
turns: ChatTurn[],
|
turns: ChatTurn[],
|
||||||
text: string,
|
text: string,
|
||||||
attachments: ChatPromptAttachment[] = [],
|
attachments: string[] = [],
|
||||||
): ChatTurn[] {
|
): ChatTurn[] {
|
||||||
const last = turns[turns.length - 1];
|
const last = turns[turns.length - 1];
|
||||||
if (
|
if (
|
||||||
@ -268,6 +228,10 @@ function normalizeProgress(raw: unknown): ReplyProgress | null {
|
|||||||
if (!label) return null;
|
if (!label) return null;
|
||||||
return {
|
return {
|
||||||
progressId: optionalString(record.progressId),
|
progressId: optionalString(record.progressId),
|
||||||
|
occurredAtMs:
|
||||||
|
typeof record.occurredAtMs === "number" && Number.isFinite(record.occurredAtMs)
|
||||||
|
? record.occurredAtMs
|
||||||
|
: undefined,
|
||||||
source: isReplyProgressSource(record.source) ? record.source : "providerNative",
|
source: isReplyProgressSource(record.source) ? record.source : "providerNative",
|
||||||
kind: isReplyProgressKind(record.kind) ? record.kind : "other",
|
kind: isReplyProgressKind(record.kind) ? record.kind : "other",
|
||||||
stage: isReplyProgressStage(record.stage) ? record.stage : "info",
|
stage: isReplyProgressStage(record.stage) ? record.stage : "info",
|
||||||
@ -281,7 +245,6 @@ function normalizeProgress(raw: unknown): ReplyProgress | null {
|
|||||||
|
|
||||||
function progressKey(progress: ReplyProgress): string {
|
function progressKey(progress: ReplyProgress): string {
|
||||||
return [
|
return [
|
||||||
progress.progressId ?? "",
|
|
||||||
progress.source,
|
progress.source,
|
||||||
progress.kind,
|
progress.kind,
|
||||||
progress.stage,
|
progress.stage,
|
||||||
@ -291,6 +254,14 @@ function progressKey(progress: ReplyProgress): string {
|
|||||||
].join("\u0000");
|
].join("\u0000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function progressUpsertKey(progress: ReplyProgress): string {
|
||||||
|
const progressId = progress.progressId?.trim();
|
||||||
|
if (progressId) return progressId;
|
||||||
|
const entity = progressEntityKey(progress);
|
||||||
|
if (entity) return `legacy-entity:${entity}`;
|
||||||
|
return `legacy:${progressDisplayKey(progress)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
function normalizedProgressToolName(progress: ReplyProgress): string | null {
|
||||||
const name = progress.toolName ?? progress.label;
|
const name = progress.toolName ?? progress.label;
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
@ -303,7 +274,6 @@ function isDelegationProgress(progress: ReplyProgress): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function progressEntityKey(progress: ReplyProgress): string | null {
|
function progressEntityKey(progress: ReplyProgress): string | null {
|
||||||
if (progress.progressId) return `id:${progress.progressId}`;
|
|
||||||
const name = normalizedProgressToolName(progress);
|
const name = normalizedProgressToolName(progress);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
if (isDelegationProgress(progress)) return `delegation:${name}`;
|
if (isDelegationProgress(progress)) return `delegation:${name}`;
|
||||||
@ -312,7 +282,7 @@ function progressEntityKey(progress: ReplyProgress): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function progressStageBucket(progress: ReplyProgress): string {
|
function progressStageBucket(progress: ReplyProgress): string {
|
||||||
if (!progressEntityKey(progress)) return progress.stage;
|
if (!isCenteredToolProgress(progress)) return progress.stage;
|
||||||
if (progress.stage === "started" || progress.stage === "delta") return "started";
|
if (progress.stage === "started" || progress.stage === "delta") return "started";
|
||||||
if (progress.stage === "completed") return "completed";
|
if (progress.stage === "completed") return "completed";
|
||||||
return "info";
|
return "info";
|
||||||
@ -326,54 +296,61 @@ function progressDisplayKey(progress: ReplyProgress): string {
|
|||||||
|
|
||||||
interface ChatViewState {
|
interface ChatViewState {
|
||||||
turns: ChatTurn[];
|
turns: ChatTurn[];
|
||||||
entityProgressMap: Map<string, ReplyProgress>;
|
progressIndex: Map<string, ProgressIndexEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyChatViewState(): ChatViewState {
|
function emptyChatViewState(): ChatViewState {
|
||||||
return { turns: [], entityProgressMap: new Map() };
|
return { turns: [], progressIndex: new Map() };
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState {
|
||||||
const entity = progressEntityKey(progress);
|
const key = progressUpsertKey(progress);
|
||||||
if (entity) {
|
const existingIndex = state.turns.findIndex(
|
||||||
const bucket = progressStageBucket(progress);
|
(turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key,
|
||||||
const key = `${entity}\u0000${bucket}`;
|
);
|
||||||
if (bucket === "started") {
|
const existingProgress =
|
||||||
const nextMap = new Map(state.entityProgressMap);
|
existingIndex >= 0 && state.turns[existingIndex].role === "progress"
|
||||||
nextMap.set(key, progress);
|
? state.turns[existingIndex].progress
|
||||||
return { turns: state.turns, entityProgressMap: nextMap };
|
: null;
|
||||||
}
|
const merged =
|
||||||
if (bucket === "completed") {
|
existingProgress && progress.stage === "completed"
|
||||||
const runningKey = `${entity}\u0000started`;
|
? mergeCompletedProgress(existingProgress, progress)
|
||||||
const running = state.entityProgressMap.get(runningKey);
|
: progress;
|
||||||
const nextMap = new Map(state.entityProgressMap);
|
const nextIndex = new Map(state.progressIndex);
|
||||||
nextMap.delete(runningKey);
|
const existingEntry = nextIndex.get(key);
|
||||||
if (
|
const firstOccurredAtMs =
|
||||||
!running &&
|
existingEntry?.firstOccurredAtMs ??
|
||||||
state.turns.some(
|
progress.occurredAtMs ??
|
||||||
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
Date.now();
|
||||||
)
|
const sequence = existingEntry?.sequence ?? nextIndex.size;
|
||||||
) {
|
nextIndex.set(key, { firstOccurredAtMs, sequence });
|
||||||
return { turns: state.turns, entityProgressMap: nextMap };
|
|
||||||
}
|
if (existingIndex >= 0) {
|
||||||
const completed = running ? mergeCompletedProgress(running, progress) : progress;
|
const nextTurns = [...state.turns];
|
||||||
return {
|
nextTurns[existingIndex] = { role: "progress", progress: merged };
|
||||||
turns: [...state.turns, { role: "progress", progress: completed }],
|
return { turns: nextTurns, progressIndex: nextIndex };
|
||||||
entityProgressMap: nextMap,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const key = progressDisplayKey(progress);
|
|
||||||
if (
|
const insertAt = state.turns.findIndex((turn) => {
|
||||||
state.turns.some(
|
if (turn.role !== "progress") return false;
|
||||||
(turn) => turn.role === "progress" && progressDisplayKey(turn.progress) === key,
|
const candidate = state.progressIndex.get(progressUpsertKey(turn.progress));
|
||||||
)
|
if (!candidate) return false;
|
||||||
) {
|
if (candidate.firstOccurredAtMs !== firstOccurredAtMs) {
|
||||||
return state;
|
return candidate.firstOccurredAtMs > firstOccurredAtMs;
|
||||||
|
}
|
||||||
|
return candidate.sequence > sequence;
|
||||||
|
});
|
||||||
|
const nextTurn = { role: "progress" as const, progress: merged };
|
||||||
|
if (insertAt < 0) {
|
||||||
|
return { turns: [...state.turns, nextTurn], progressIndex: nextIndex };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
turns: [...state.turns, { role: "progress", progress }],
|
turns: [
|
||||||
entityProgressMap: state.entityProgressMap,
|
...state.turns.slice(0, insertAt),
|
||||||
|
nextTurn,
|
||||||
|
...state.turns.slice(insertAt),
|
||||||
|
],
|
||||||
|
progressIndex: nextIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -430,13 +407,9 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
function completeChatViewProgress(state: ChatViewState): ChatViewState {
|
||||||
const activeTurns = Array.from(state.entityProgressMap.values()).map((progress) => ({
|
|
||||||
role: "progress" as const,
|
|
||||||
progress,
|
|
||||||
}));
|
|
||||||
return {
|
return {
|
||||||
turns: completeRunningProgress([...state.turns, ...activeTurns]),
|
turns: completeRunningProgress(state.turns),
|
||||||
entityProgressMap: new Map(),
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,7 +418,6 @@ function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function turnsIndicateBusy(state: ChatViewState): boolean {
|
function turnsIndicateBusy(state: ChatViewState): boolean {
|
||||||
if (state.entityProgressMap.size > 0) return true;
|
|
||||||
for (let index = state.turns.length - 1; index >= 0; index -= 1) {
|
for (let index = state.turns.length - 1; index >= 0; index -= 1) {
|
||||||
const turn = state.turns[index];
|
const turn = state.turns[index];
|
||||||
if (turn.role === "final" || turn.role === "error") return false;
|
if (turn.role === "final" || turn.role === "error") return false;
|
||||||
@ -461,6 +433,26 @@ function turnsIndicateBusy(state: ChatViewState): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertTextAtSelection(
|
||||||
|
text: string,
|
||||||
|
insertion: string,
|
||||||
|
selectionStart: number,
|
||||||
|
selectionEnd: number,
|
||||||
|
): string {
|
||||||
|
return text.slice(0, selectionStart) + insertion + text.slice(selectionEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachmentToken(text: string, token: string): string {
|
||||||
|
const index = text.indexOf(token);
|
||||||
|
if (index < 0) return text;
|
||||||
|
const before = text.slice(0, index);
|
||||||
|
const after = text.slice(index + token.length);
|
||||||
|
if (/\s$/.test(before) && /^\s/.test(after)) {
|
||||||
|
return before + after.replace(/^\s+/, "");
|
||||||
|
}
|
||||||
|
return before + after;
|
||||||
|
}
|
||||||
|
|
||||||
function fileExtension(mime: string): string {
|
function fileExtension(mime: string): string {
|
||||||
if (mime === "image/png") return "png";
|
if (mime === "image/png") return "png";
|
||||||
if (mime === "image/jpeg") return "jpg";
|
if (mime === "image/jpeg") return "jpg";
|
||||||
@ -584,7 +576,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
|||||||
if (!isReplyRecord(raw)) {
|
if (!isReplyRecord(raw)) {
|
||||||
return {
|
return {
|
||||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||||
entityProgressMap: state.entityProgressMap,
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const last = turns[turns.length - 1];
|
const last = turns[turns.length - 1];
|
||||||
@ -602,19 +594,19 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
|||||||
case "textDelta":
|
case "textDelta":
|
||||||
return {
|
return {
|
||||||
turns: appendAgentDelta(turns, String(raw.text ?? "")),
|
turns: appendAgentDelta(turns, String(raw.text ?? "")),
|
||||||
entityProgressMap: state.entityProgressMap,
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
case "toolActivity":
|
case "toolActivity":
|
||||||
return {
|
return {
|
||||||
turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }],
|
turns: [...turns, { role: "tool", label: String(raw.label ?? "Activité") }],
|
||||||
entityProgressMap: state.entityProgressMap,
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
case "progress": {
|
case "progress": {
|
||||||
const progress = normalizeProgress(raw.progress);
|
const progress = normalizeProgress(raw.progress);
|
||||||
if (!progress) {
|
if (!progress) {
|
||||||
return {
|
return {
|
||||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||||
entityProgressMap: state.entityProgressMap,
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return appendProgress(state, progress);
|
return appendProgress(state, progress);
|
||||||
@ -626,7 +618,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
|||||||
const last = next[next.length - 1];
|
const last = next[next.length - 1];
|
||||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||||
next.push({ role: "final", text: content });
|
next.push({ role: "final", text: content });
|
||||||
return { turns: next, entityProgressMap: completedState.entityProgressMap };
|
return { turns: next, progressIndex: completedState.progressIndex };
|
||||||
}
|
}
|
||||||
case "error": {
|
case "error": {
|
||||||
const completedState = completeChatViewProgress(state);
|
const completedState = completeChatViewProgress(state);
|
||||||
@ -634,7 +626,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
|||||||
const last = next[next.length - 1];
|
const last = next[next.length - 1];
|
||||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||||
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
|
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
|
||||||
return { turns: next, entityProgressMap: completedState.entityProgressMap };
|
return { turns: next, progressIndex: completedState.progressIndex };
|
||||||
}
|
}
|
||||||
case "userPrompt":
|
case "userPrompt":
|
||||||
case "UserPrompt":
|
case "UserPrompt":
|
||||||
@ -642,14 +634,14 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
|||||||
turns: appendUserPrompt(
|
turns: appendUserPrompt(
|
||||||
turns,
|
turns,
|
||||||
String(raw.text ?? raw.prompt ?? ""),
|
String(raw.text ?? raw.prompt ?? ""),
|
||||||
attachmentRefs(raw.attachmentRefs, attachmentLabels(raw.attachments)),
|
attachmentLabels(raw.attachments),
|
||||||
),
|
),
|
||||||
entityProgressMap: state.entityProgressMap,
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
turns: [...turns, { role: "unknown", text: unknownChunkLabel(raw) }],
|
||||||
entityProgressMap: state.entityProgressMap,
|
progressIndex: state.progressIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -664,6 +656,9 @@ export function CustomAgentChatView({
|
|||||||
nodeId,
|
nodeId,
|
||||||
sessionId,
|
sessionId,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||||
|
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||||
|
onComposerHeightPxChange,
|
||||||
onSessionId,
|
onSessionId,
|
||||||
onConversationId,
|
onConversationId,
|
||||||
}: CustomAgentChatViewProps) {
|
}: CustomAgentChatViewProps) {
|
||||||
@ -672,7 +667,7 @@ export function CustomAgentChatView({
|
|||||||
const [turns, setTurns] = useState<ChatTurn[]>(
|
const [turns, setTurns] = useState<ChatTurn[]>(
|
||||||
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
() => (sessionId ? (chatSessionViewCache.get(sessionId)?.turns ?? []) : []),
|
||||||
);
|
);
|
||||||
const [entityProgressMap, setEntityProgressMap] = useState<Map<string, ReplyProgress>>(new Map());
|
const [progressIndex, setProgressIndex] = useState<Map<string, ProgressIndexEntry>>(new Map());
|
||||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||||
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
const [externalSessionId, setExternalSessionId] = useState(sessionId);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
@ -688,8 +683,9 @@ export function CustomAgentChatView({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const turnsRef = useRef(turns);
|
const turnsRef = useRef(turns);
|
||||||
const entityProgressMapRef = useRef(entityProgressMap);
|
const progressIndexRef = useRef(progressIndex);
|
||||||
const sessionRef = useRef<string | null>(sessionId);
|
const sessionRef = useRef<string | null>(sessionId);
|
||||||
sessionRef.current = currentSession;
|
sessionRef.current = currentSession;
|
||||||
const scrollbackChunkCountRef = useRef(
|
const scrollbackChunkCountRef = useRef(
|
||||||
@ -706,6 +702,9 @@ export function CustomAgentChatView({
|
|||||||
const lastPersistedDraftRef = useRef("");
|
const lastPersistedDraftRef = useRef("");
|
||||||
const draftRef = useRef("");
|
const draftRef = useRef("");
|
||||||
draftRef.current = draft;
|
draftRef.current = draft;
|
||||||
|
const attachmentsRef = useRef<AttachmentDraft[]>([]);
|
||||||
|
attachmentsRef.current = attachments;
|
||||||
|
const imageTokenSeqRef = useRef(0);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const openOrAttachCountRef = useRef(0);
|
const openOrAttachCountRef = useRef(0);
|
||||||
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
|
||||||
@ -714,11 +713,68 @@ export function CustomAgentChatView({
|
|||||||
const onConversationIdRef = useRef(onConversationId);
|
const onConversationIdRef = useRef(onConversationId);
|
||||||
onConversationIdRef.current = onConversationId;
|
onConversationIdRef.current = onConversationId;
|
||||||
|
|
||||||
|
const clampComposerHeight = useCallback(
|
||||||
|
(heightPx: number): number => {
|
||||||
|
const visibleHeight =
|
||||||
|
chatRootRef.current?.getBoundingClientRect().height ??
|
||||||
|
composerRef.current?.closest<HTMLElement>("[data-custom-chat-shell]")
|
||||||
|
?.getBoundingClientRect().height ??
|
||||||
|
0;
|
||||||
|
const maxHeight =
|
||||||
|
visibleHeight > 0
|
||||||
|
? Math.max(composerDefaultHeightPx, Math.floor(visibleHeight * 0.5))
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
return Math.round(
|
||||||
|
Math.min(Math.max(heightPx, composerDefaultHeightPx), maxHeight),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[composerDefaultHeightPx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitComposerHeight = useCallback(
|
||||||
|
(heightPx: number): void => {
|
||||||
|
onComposerHeightPxChange?.(clampComposerHeight(heightPx));
|
||||||
|
},
|
||||||
|
[clampComposerHeight, onComposerHeightPxChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startComposerResize = useCallback(
|
||||||
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startHeight = composerHeightPx;
|
||||||
|
const onMove = (moveEvent: MouseEvent) => {
|
||||||
|
commitComposerHeight(startHeight + startY - moveEvent.clientY);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
},
|
||||||
|
[commitComposerHeight, composerHeightPx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleComposerResizeKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitComposerHeight(composerHeightPx + COMPOSER_RESIZE_STEP);
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitComposerHeight(composerHeightPx - COMPOSER_RESIZE_STEP);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commitComposerHeight, composerHeightPx],
|
||||||
|
);
|
||||||
|
|
||||||
const applyChatViewState = useCallback((next: ChatViewState) => {
|
const applyChatViewState = useCallback((next: ChatViewState) => {
|
||||||
turnsRef.current = next.turns;
|
turnsRef.current = next.turns;
|
||||||
entityProgressMapRef.current = next.entityProgressMap;
|
progressIndexRef.current = next.progressIndex;
|
||||||
setTurns(next.turns);
|
setTurns(next.turns);
|
||||||
setEntityProgressMap(next.entityProgressMap);
|
setProgressIndex(next.progressIndex);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateChatViewState = useCallback(
|
const updateChatViewState = useCallback(
|
||||||
@ -726,7 +782,7 @@ export function CustomAgentChatView({
|
|||||||
applyChatViewState(
|
applyChatViewState(
|
||||||
updater({
|
updater({
|
||||||
turns: turnsRef.current,
|
turns: turnsRef.current,
|
||||||
entityProgressMap: entityProgressMapRef.current,
|
progressIndex: progressIndexRef.current,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -746,16 +802,7 @@ export function CustomAgentChatView({
|
|||||||
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
||||||
[layoutId, nodeId, projectId],
|
[layoutId, nodeId, projectId],
|
||||||
);
|
);
|
||||||
const displayTurns = useMemo<ChatTurn[]>(
|
const displayTurns = turns;
|
||||||
() => [
|
|
||||||
...turns,
|
|
||||||
...Array.from(entityProgressMap.values()).map((progress) => ({
|
|
||||||
role: "progress" as const,
|
|
||||||
progress,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
[entityProgressMap, turns],
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearPersistedDraft = useCallback(async () => {
|
const clearPersistedDraft = useCallback(async () => {
|
||||||
if (!draftScope) return;
|
if (!draftScope) return;
|
||||||
@ -827,10 +874,10 @@ export function CustomAgentChatView({
|
|||||||
if (!currentSession) return;
|
if (!currentSession) return;
|
||||||
chatSessionViewCache.set(currentSession, {
|
chatSessionViewCache.set(currentSession, {
|
||||||
turns,
|
turns,
|
||||||
entityProgressMap,
|
progressIndex,
|
||||||
chunkCount: scrollbackChunkCountRef.current,
|
chunkCount: scrollbackChunkCountRef.current,
|
||||||
});
|
});
|
||||||
}, [currentSession, entityProgressMap, turns]);
|
}, [currentSession, progressIndex, turns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
draftSaveSeqRef.current += 1;
|
draftSaveSeqRef.current += 1;
|
||||||
@ -1128,7 +1175,7 @@ export function CustomAgentChatView({
|
|||||||
scrollbackChunkCountRef.current = cached.chunkCount;
|
scrollbackChunkCountRef.current = cached.chunkCount;
|
||||||
applyChatViewState({
|
applyChatViewState({
|
||||||
turns: cached.turns,
|
turns: cached.turns,
|
||||||
entityProgressMap: cached.entityProgressMap ?? new Map(),
|
progressIndex: cached.progressIndex ?? new Map(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -1272,7 +1319,7 @@ export function CustomAgentChatView({
|
|||||||
|
|
||||||
applyChatViewState({
|
applyChatViewState({
|
||||||
turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }],
|
turns: [{ role: "tool", label: `Profil changé vers ${selected.name}.` }],
|
||||||
entityProgressMap: new Map(),
|
progressIndex: new Map(),
|
||||||
});
|
});
|
||||||
setProfileDialog(null);
|
setProfileDialog(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1307,7 +1354,7 @@ export function CustomAgentChatView({
|
|||||||
const helpText = formatSlashCommandHelp(result.effect.commands);
|
const helpText = formatSlashCommandHelp(result.effect.commands);
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
turns: [...prev.turns, { role: "agent", text: helpText }],
|
turns: [...prev.turns, { role: "agent", text: helpText }],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1330,7 +1377,7 @@ export function CustomAgentChatView({
|
|||||||
...prev.turns,
|
...prev.turns,
|
||||||
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
|
{ role: "tool", label: `Commande plugin ${result.command.name} exécutée.` },
|
||||||
],
|
],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1339,7 +1386,7 @@ export function CustomAgentChatView({
|
|||||||
setError(message);
|
setError(message);
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
turns: [...prev.turns, { role: "error", text: message }],
|
turns: [...prev.turns, { role: "error", text: message }],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1358,6 +1405,15 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeAttachment(item: AttachmentDraft) {
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.filter((candidate) => candidate.id !== item.id),
|
||||||
|
);
|
||||||
|
if (item.token) {
|
||||||
|
editDraft(removeAttachmentToken(draftRef.current, item.token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pasteClipboardFiles(
|
async function pasteClipboardFiles(
|
||||||
event: ClipboardEvent<HTMLTextAreaElement>,
|
event: ClipboardEvent<HTMLTextAreaElement>,
|
||||||
) {
|
) {
|
||||||
@ -1375,26 +1431,42 @@ export function CustomAgentChatView({
|
|||||||
const nextAttachments = await Promise.all(
|
const nextAttachments = await Promise.all(
|
||||||
files.map(clipboardFileToAttachment),
|
files.map(clipboardFileToAttachment),
|
||||||
);
|
);
|
||||||
setAttachments((prev) => {
|
const known = new Set(
|
||||||
const known = new Set(
|
attachmentsRef.current
|
||||||
prev
|
.map((attachment) => attachment.dedupeKey)
|
||||||
.map((attachment) => attachment.dedupeKey)
|
.filter((key): key is string => Boolean(key)),
|
||||||
.filter((key): key is string => Boolean(key)),
|
);
|
||||||
);
|
const uniqueAttachments = nextAttachments
|
||||||
const unique = nextAttachments.filter((attachment) => {
|
.filter((attachment) => {
|
||||||
if (!attachment.dedupeKey) return true;
|
if (!attachment.dedupeKey) return true;
|
||||||
if (known.has(attachment.dedupeKey)) return false;
|
if (known.has(attachment.dedupeKey)) return false;
|
||||||
known.add(attachment.dedupeKey);
|
known.add(attachment.dedupeKey);
|
||||||
return true;
|
return true;
|
||||||
|
})
|
||||||
|
.map((attachment) => {
|
||||||
|
if (!attachment.previewUrl) return attachment;
|
||||||
|
const token = `[Image ${++imageTokenSeqRef.current}]`;
|
||||||
|
return { ...attachment, token };
|
||||||
});
|
});
|
||||||
return unique.length > 0 ? [...prev, ...unique] : prev;
|
if (uniqueAttachments.length > 0) {
|
||||||
});
|
setAttachments((prev) => [...prev, ...uniqueAttachments]);
|
||||||
if (filesFromEvent.length > 0 && pastedText) {
|
}
|
||||||
|
const imageTokens = uniqueAttachments
|
||||||
|
.map((attachment) => attachment.token)
|
||||||
|
.filter((token): token is string => Boolean(token));
|
||||||
|
const insertionParts = [
|
||||||
|
...(pastedText ? [pastedText] : []),
|
||||||
|
...(imageTokens.length > 0 ? [imageTokens.join(" ")] : []),
|
||||||
|
];
|
||||||
|
if (insertionParts.length > 0) {
|
||||||
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
if (!draftHydratedRef.current) draftEditedBeforeHydrationRef.current = true;
|
||||||
setDraft((prev) =>
|
setDraft((prev) =>
|
||||||
prev.slice(0, selectionStart) +
|
insertTextAtSelection(
|
||||||
pastedText +
|
prev,
|
||||||
prev.slice(selectionEnd),
|
insertionParts.join(" "),
|
||||||
|
selectionStart,
|
||||||
|
selectionEnd,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1412,7 +1484,7 @@ export function CustomAgentChatView({
|
|||||||
}
|
}
|
||||||
const outgoingAttachments = attachments;
|
const outgoingAttachments = attachments;
|
||||||
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
||||||
const attachmentRefs = attachmentRefsFromDrafts(outgoingAttachments);
|
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||||
const displayText = text || "Pièce jointe";
|
const displayText = text || "Pièce jointe";
|
||||||
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
||||||
activeInteractionIdRef.current = interactionId;
|
activeInteractionIdRef.current = interactionId;
|
||||||
@ -1424,6 +1496,7 @@ export function CustomAgentChatView({
|
|||||||
};
|
};
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
imageTokenSeqRef.current = 0;
|
||||||
void clearPersistedDraft();
|
void clearPersistedDraft();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -1433,10 +1506,10 @@ export function CustomAgentChatView({
|
|||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
text: displayText,
|
text: displayText,
|
||||||
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
|
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
const sendTurn = (sid: string) =>
|
const sendTurn = (sid: string) =>
|
||||||
attachmentInputs.length > 0
|
attachmentInputs.length > 0
|
||||||
@ -1469,7 +1542,7 @@ export function CustomAgentChatView({
|
|||||||
setError(describe(recoveryError));
|
setError(describe(recoveryError));
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
turns: [...prev.turns, { role: "error", text: describe(recoveryError) }],
|
turns: [...prev.turns, { role: "error", text: describe(recoveryError) }],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1479,7 +1552,7 @@ export function CustomAgentChatView({
|
|||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
updateChatViewState((prev) => ({
|
updateChatViewState((prev) => ({
|
||||||
turns: [...prev.turns, { role: "error", text: describe(e) }],
|
turns: [...prev.turns, { role: "error", text: describe(e) }],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1512,7 +1585,7 @@ export function CustomAgentChatView({
|
|||||||
: "Agent arrêté.",
|
: "Agent arrêté.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
entityProgressMap: completed.entityProgressMap,
|
progressIndex: completed.progressIndex,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1532,7 +1605,7 @@ export function CustomAgentChatView({
|
|||||||
...completed.turns,
|
...completed.turns,
|
||||||
{ role: "tool", label: "Session déjà arrêtée." },
|
{ role: "tool", label: "Session déjà arrêtée." },
|
||||||
],
|
],
|
||||||
entityProgressMap: completed.entityProgressMap,
|
progressIndex: completed.progressIndex,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -1543,7 +1616,7 @@ export function CustomAgentChatView({
|
|||||||
...prev.turns,
|
...prev.turns,
|
||||||
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
{ role: "error", text: `Arrêt de l'agent échoué: ${describe(e)}` },
|
||||||
],
|
],
|
||||||
entityProgressMap: prev.entityProgressMap,
|
progressIndex: prev.progressIndex,
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
setStopping(false);
|
setStopping(false);
|
||||||
@ -1552,6 +1625,7 @@ export function CustomAgentChatView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={chatRootRef}
|
||||||
data-testid="custom-agent-chat-view"
|
data-testid="custom-agent-chat-view"
|
||||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
|
className="flex h-full min-h-0 flex-col overflow-hidden bg-surface text-content"
|
||||||
>
|
>
|
||||||
@ -1721,11 +1795,7 @@ export function CustomAgentChatView({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`retirer ${item.label}`}
|
aria-label={`retirer ${item.label}`}
|
||||||
className="shrink-0 text-content"
|
className="shrink-0 text-content"
|
||||||
onClick={() =>
|
onClick={() => removeAttachment(item)}
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.filter((candidate) => candidate.id !== item.id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Retirer
|
Retirer
|
||||||
</button>
|
</button>
|
||||||
@ -1772,55 +1842,73 @@ export function CustomAgentChatView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 items-end gap-2">
|
<div className="flex min-w-0 items-end gap-2">
|
||||||
<textarea
|
<div className="relative flex min-w-0 flex-1">
|
||||||
ref={composerRef}
|
<textarea
|
||||||
aria-label={`message CLI custom ${nodeId}`}
|
ref={composerRef}
|
||||||
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
|
aria-label={`message CLI custom ${nodeId}`}
|
||||||
aria-activedescendant={
|
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
|
||||||
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
|
aria-activedescendant={
|
||||||
}
|
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
|
||||||
className={cn(
|
|
||||||
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
|
|
||||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
)}
|
|
||||||
rows={2}
|
|
||||||
value={draft}
|
|
||||||
disabled={!supported || opening || busy}
|
|
||||||
placeholder="Message à l'agent…"
|
|
||||||
onChange={(e) => editDraft(e.target.value)}
|
|
||||||
onPaste={(e) => void pasteClipboardFiles(e)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (slashMenuOpen && slashCommands.length > 0) {
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSlashActiveIndex(
|
|
||||||
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSlashMenuOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter" || e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
const command = slashCommands[slashActiveIndex];
|
|
||||||
if (command) selectSlashCommand(command);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
className={cn(
|
||||||
|
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 pr-7 text-sm text-content outline-none",
|
||||||
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
)}
|
||||||
|
rows={2}
|
||||||
|
value={draft}
|
||||||
|
disabled={!supported || opening || busy}
|
||||||
|
placeholder="Message à l'agent…"
|
||||||
|
style={{ height: clampComposerHeight(composerHeightPx) }}
|
||||||
|
onChange={(e) => editDraft(e.target.value)}
|
||||||
|
onPaste={(e) => void pasteClipboardFiles(e)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (slashMenuOpen && slashCommands.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashActiveIndex(
|
||||||
|
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashMenuOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
const command = slashCommands[slashActiveIndex];
|
||||||
|
if (command) selectSlashCommand(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`redimensionner le composer ${nodeId}`}
|
||||||
|
title="Redimensionner le composer"
|
||||||
|
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm border border-border bg-raised text-[9px] leading-3 text-muted hover:text-content focus:border-primary focus:outline-none"
|
||||||
|
style={{ cursor: "ns-resize" }}
|
||||||
|
onMouseDown={startComposerResize}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void send();
|
commitComposerHeight(composerDefaultHeightPx);
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={handleComposerResizeKeyDown}
|
||||||
/>
|
>
|
||||||
|
↕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -1888,6 +1976,9 @@ function ChatBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 justify-center">
|
<div className="flex min-w-0 justify-center">
|
||||||
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
|
<div className="min-w-0 max-w-[86%] overflow-hidden rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||||
|
{turn.role === "error" && (
|
||||||
|
<div className="mb-1 font-semibold">Échec</div>
|
||||||
|
)}
|
||||||
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
|
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
|
||||||
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -1908,8 +1999,8 @@ function ChatBubble({
|
|||||||
{user && turn.attachments && turn.attachments.length > 0 && (
|
{user && turn.attachments && turn.attachments.length > 0 && (
|
||||||
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
||||||
{turn.attachments.map((attachment) => (
|
{turn.attachments.map((attachment) => (
|
||||||
<p key={`${attachment.reference}-${attachment.filename}`} className="truncate">
|
<p key={attachment} className="truncate">
|
||||||
{attachment.reference} {attachment.filename}
|
Fichier: {attachment}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -1942,11 +2033,11 @@ function progressSourceLabel(progress: ReplyProgress): string {
|
|||||||
function progressStageLabel(stage: ReplyProgressStage): string {
|
function progressStageLabel(stage: ReplyProgressStage): string {
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case "started":
|
case "started":
|
||||||
return "started";
|
return "En cours";
|
||||||
case "delta":
|
case "delta":
|
||||||
return "running";
|
return "En cours";
|
||||||
case "completed":
|
case "completed":
|
||||||
return "done";
|
return "Terminé";
|
||||||
case "info":
|
case "info":
|
||||||
return "info";
|
return "info";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -180,35 +180,47 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
|||||||
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes the custom CLI chat locally per cell and can reset to the default size", async () => {
|
it("persists custom CLI composer height per layout cell", async () => {
|
||||||
|
const rectSpy = vi
|
||||||
|
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||||||
|
.mockReturnValue({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 600,
|
||||||
|
height: 400,
|
||||||
|
top: 0,
|
||||||
|
right: 600,
|
||||||
|
bottom: 400,
|
||||||
|
left: 0,
|
||||||
|
toJSON: () => {},
|
||||||
|
} as DOMRect);
|
||||||
const gateways = await seeded(structuredProfile);
|
const gateways = await seeded(structuredProfile);
|
||||||
const layout = gateways.layout as MockLayoutGateway;
|
const layout = gateways.layout as MockLayoutGateway;
|
||||||
const tree = await layout.loadLayout("p1");
|
const tree = await layout.loadLayout("p1");
|
||||||
const leafId = leaves(tree)[0].id;
|
const leafId = leaves(tree)[0].id;
|
||||||
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
||||||
|
|
||||||
renderGrid(gateways);
|
try {
|
||||||
|
renderGrid(gateways);
|
||||||
|
|
||||||
const shell = await screen.findByTestId("custom-agent-chat-shell");
|
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
||||||
expect(shell.style.width).toBe("100%");
|
const handle = screen.getByRole("button", {
|
||||||
expect(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`)).toBeNull();
|
name: `redimensionner le composer ${leafId}`,
|
||||||
|
});
|
||||||
|
expect(composer.style.height).toBe("64px");
|
||||||
|
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
|
||||||
|
.toBeNull();
|
||||||
|
|
||||||
fireEvent.mouseDown(screen.getByRole("button", { name: `resize custom chat ${leafId}` }), {
|
fireEvent.mouseDown(handle, { clientY: 200 });
|
||||||
clientX: 0,
|
fireEvent.mouseMove(window, { clientY: 100 });
|
||||||
clientY: 0,
|
fireEvent.mouseUp(window);
|
||||||
});
|
|
||||||
fireEvent.mouseMove(window, { clientX: 640, clientY: 420 });
|
|
||||||
fireEvent.mouseUp(window);
|
|
||||||
|
|
||||||
await waitFor(() => expect(shell.style.width).toBe("640px"));
|
await waitFor(() => expect(composer.style.height).toBe("164px"));
|
||||||
expect(shell.style.height).toBe("420px");
|
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
|
||||||
expect(JSON.parse(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`) ?? "{}"))
|
.toBe("164");
|
||||||
.toEqual({ width: 640, height: 420 });
|
} finally {
|
||||||
|
rectSpy.mockRestore();
|
||||||
fireEvent.click(screen.getByRole("button", { name: `reset custom chat size ${leafId}` }));
|
}
|
||||||
|
|
||||||
await waitFor(() => expect(shell.style.width).toBe("100%"));
|
|
||||||
expect(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
|
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
* {@link normalizeWeights} function, kept out of the render for testability.
|
* {@link normalizeWeights} function, kept out of the render for testability.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { Agent, AgentProfile } from "@/domain";
|
import type { Agent, AgentProfile } from "@/domain";
|
||||||
import type { LayoutNode } from "@/domain";
|
import type { LayoutNode } from "@/domain";
|
||||||
@ -407,47 +407,16 @@ const CELL_Z = {
|
|||||||
turnActions: 6,
|
turnActions: 6,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const CUSTOM_CHAT_MIN_WIDTH = 360;
|
const CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT = 64;
|
||||||
const CUSTOM_CHAT_MIN_HEIGHT = 280;
|
|
||||||
const CUSTOM_CHAT_MAX_WIDTH = 1200;
|
|
||||||
const CUSTOM_CHAT_MAX_HEIGHT = 900;
|
|
||||||
|
|
||||||
interface CustomChatSize {
|
function readCustomChatComposerHeight(storageKey: string): number {
|
||||||
width: number;
|
if (typeof window === "undefined") return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampCustomChatSize(size: CustomChatSize): CustomChatSize {
|
|
||||||
const viewportWidth =
|
|
||||||
typeof window === "undefined" ? CUSTOM_CHAT_MAX_WIDTH : window.innerWidth - 24;
|
|
||||||
const viewportHeight =
|
|
||||||
typeof window === "undefined" ? CUSTOM_CHAT_MAX_HEIGHT : window.innerHeight - 24;
|
|
||||||
return {
|
|
||||||
width: Math.round(
|
|
||||||
Math.min(
|
|
||||||
Math.max(size.width, CUSTOM_CHAT_MIN_WIDTH),
|
|
||||||
Math.max(CUSTOM_CHAT_MIN_WIDTH, Math.min(CUSTOM_CHAT_MAX_WIDTH, viewportWidth)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
height: Math.round(
|
|
||||||
Math.min(
|
|
||||||
Math.max(size.height, CUSTOM_CHAT_MIN_HEIGHT),
|
|
||||||
Math.max(CUSTOM_CHAT_MIN_HEIGHT, Math.min(CUSTOM_CHAT_MAX_HEIGHT, viewportHeight)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCustomChatSize(storageKey: string): CustomChatSize | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(storageKey);
|
const parsed = Number(window.localStorage.getItem(storageKey));
|
||||||
if (!raw) return null;
|
if (!Number.isFinite(parsed)) return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
|
||||||
const parsed = JSON.parse(raw) as Partial<CustomChatSize>;
|
return Math.max(CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT, Math.round(parsed));
|
||||||
if (typeof parsed.width !== "number" || typeof parsed.height !== "number") return null;
|
|
||||||
return clampCustomChatSize({ width: parsed.width, height: parsed.height });
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,53 +539,25 @@ function LeafView({
|
|||||||
},
|
},
|
||||||
[cellModeStorageKey],
|
[cellModeStorageKey],
|
||||||
);
|
);
|
||||||
const customChatSizeStorageKey = `idea.custom-chat-size.${projectId}.${id}`;
|
const composerHeightStorageKey = `idea.custom-chat-composer-height.${projectId}.${id}`;
|
||||||
const [customChatSize, setCustomChatSizeState] = useState<CustomChatSize | null>(() =>
|
const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() =>
|
||||||
readCustomChatSize(customChatSizeStorageKey),
|
readCustomChatComposerHeight(composerHeightStorageKey),
|
||||||
);
|
);
|
||||||
const setCustomChatSize = useCallback(
|
const setCustomChatComposerHeight = useCallback(
|
||||||
(size: CustomChatSize | null) => {
|
(height: number) => {
|
||||||
setCustomChatSizeState(size);
|
const nextHeight = Math.max(
|
||||||
|
CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT,
|
||||||
|
Math.round(height),
|
||||||
|
);
|
||||||
|
setCustomChatComposerHeightState(nextHeight);
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
if (size) {
|
window.localStorage.setItem(composerHeightStorageKey, String(nextHeight));
|
||||||
window.localStorage.setItem(customChatSizeStorageKey, JSON.stringify(size));
|
|
||||||
} else {
|
|
||||||
window.localStorage.removeItem(customChatSizeStorageKey);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* local preference only */
|
/* local preference only */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[customChatSizeStorageKey],
|
[composerHeightStorageKey],
|
||||||
);
|
|
||||||
const startCustomChatResize = useCallback(
|
|
||||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
|
||||||
const container = event.currentTarget.closest<HTMLElement>("[data-custom-chat-shell]");
|
|
||||||
if (!container) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const rect = container.getBoundingClientRect();
|
|
||||||
const startX = event.clientX;
|
|
||||||
const startY = event.clientY;
|
|
||||||
const startWidth = customChatSize?.width ?? rect.width;
|
|
||||||
const startHeight = customChatSize?.height ?? rect.height;
|
|
||||||
const onMove = (moveEvent: MouseEvent) => {
|
|
||||||
setCustomChatSize(
|
|
||||||
clampCustomChatSize({
|
|
||||||
width: startWidth + moveEvent.clientX - startX,
|
|
||||||
height: startHeight + moveEvent.clientY - startY,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const onUp = () => {
|
|
||||||
window.removeEventListener("mousemove", onMove);
|
|
||||||
window.removeEventListener("mouseup", onUp);
|
|
||||||
};
|
|
||||||
window.addEventListener("mousemove", onMove);
|
|
||||||
window.addEventListener("mouseup", onUp);
|
|
||||||
},
|
|
||||||
[customChatSize, setCustomChatSize],
|
|
||||||
);
|
);
|
||||||
const [pendingModeSwitch, setPendingModeSwitch] =
|
const [pendingModeSwitch, setPendingModeSwitch] =
|
||||||
useState<PendingModeSwitch | null>(null);
|
useState<PendingModeSwitch | null>(null);
|
||||||
@ -1192,7 +1133,7 @@ function LeafView({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
border: "1px solid #2a2a2a",
|
border: "1px solid #2a2a2a",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
overflow: customChatSize ? "visible" : "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -1556,85 +1497,23 @@ function LeafView({
|
|||||||
onRefresh={() => void refreshStoppedAgentState()}
|
onRefresh={() => void refreshStoppedAgentState()}
|
||||||
/>
|
/>
|
||||||
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
||||||
<div
|
<CustomAgentChatView
|
||||||
data-custom-chat-shell
|
key={`${id}-${agentId}-custom`}
|
||||||
data-testid="custom-agent-chat-shell"
|
projectId={projectId}
|
||||||
style={{
|
agentId={agentId}
|
||||||
position: customChatSize ? "absolute" : "relative",
|
agentName={customCliAgent.name}
|
||||||
top: 0,
|
profile={customCliProfile}
|
||||||
left: 0,
|
cwd={cwd}
|
||||||
zIndex: customChatSize ? 2 : 0,
|
layoutId={layoutId}
|
||||||
width: customChatSize ? customChatSize.width : "100%",
|
nodeId={id}
|
||||||
height: customChatSize ? customChatSize.height : "100%",
|
sessionId={session}
|
||||||
minWidth: CUSTOM_CHAT_MIN_WIDTH,
|
conversationId={conversationId}
|
||||||
minHeight: CUSTOM_CHAT_MIN_HEIGHT,
|
composerHeightPx={customChatComposerHeight}
|
||||||
maxWidth: "calc(100vw - 24px)",
|
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
|
||||||
maxHeight: "calc(100vh - 24px)",
|
onComposerHeightPxChange={setCustomChatComposerHeight}
|
||||||
boxShadow: customChatSize ? "0 18px 48px rgba(0, 0, 0, 0.42)" : undefined,
|
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
|
||||||
}}
|
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
|
||||||
>
|
/>
|
||||||
<CustomAgentChatView
|
|
||||||
key={`${id}-${agentId}-custom`}
|
|
||||||
projectId={projectId}
|
|
||||||
agentId={agentId}
|
|
||||||
agentName={customCliAgent.name}
|
|
||||||
profile={customCliProfile}
|
|
||||||
cwd={cwd}
|
|
||||||
layoutId={layoutId}
|
|
||||||
nodeId={id}
|
|
||||||
sessionId={session}
|
|
||||||
conversationId={conversationId}
|
|
||||||
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
|
|
||||||
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
|
|
||||||
/>
|
|
||||||
{customChatSize && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`reset custom chat size ${id}`}
|
|
||||||
title="Reset chat size"
|
|
||||||
onClick={() => setCustomChatSize(null)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 30,
|
|
||||||
bottom: 6,
|
|
||||||
zIndex: 30,
|
|
||||||
border: "1px solid var(--color-border, #3a3a3a)",
|
|
||||||
borderRadius: 3,
|
|
||||||
background: "var(--color-surface, #1e1e1e)",
|
|
||||||
color: "var(--color-content, #e0e0e0)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 11,
|
|
||||||
padding: "1px 6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`resize custom chat ${id}`}
|
|
||||||
title="Resize chat"
|
|
||||||
onMouseDown={startCustomChatResize}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 6,
|
|
||||||
bottom: 6,
|
|
||||||
zIndex: 30,
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
border: "1px solid var(--color-border, #3a3a3a)",
|
|
||||||
borderRadius: 3,
|
|
||||||
background: "var(--color-surface, #1e1e1e)",
|
|
||||||
color: "var(--color-muted, #9aa7b4)",
|
|
||||||
cursor: "nwse-resize",
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: "12px",
|
|
||||||
padding: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↘
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<TerminalView
|
<TerminalView
|
||||||
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
|
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user