Browse Source

Let collapsed composer summaries reopen the editor

Users expect a collapsed bottom composer to expand from the visible summary itself, not only the small expand icon. The collapsed summary now acts as the expansion target while preserving the generate button's submit behavior, and a regression test locks the summary-click path.

Constraint: TEST-s deployment branch is used for the test server
Rejected: Keep model summary opening the model dialog while collapsed | conflicts with the requested one-click expand behavior
Confidence: high
Scope-risk: narrow
Directive: Keep collapsed summary clicks focused on expansion; preserve generate button isolation
Tested: npm run test:run -- src/components/__tests__/GenerationComposer.test.tsx
Tested: npm run build
Tested: Local browser check on http://localhost:3000/ collapsed summary click
Not-tested: npm run lint | existing script calls unsupported next lint under Next 16
Not-tested: npx tsc --noEmit | existing unrelated test type errors remain
feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
5a77b4050c
  1. 10
      src/components/__tests__/GenerationComposer.test.tsx
  2. 46
      src/components/composer/GenerationComposer.tsx

10
src/components/__tests__/GenerationComposer.test.tsx

@ -322,6 +322,16 @@ describe("GenerationComposer", () => {
expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument(); expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument();
}); });
it("expands the collapsed composer when clicking the summary content", () => {
useWorkflowStore.setState({ nodes: [imageNode("img-1", true, { inputPrompt: "visible prompt" })] });
render(<GenerationComposer />);
fireEvent.click(screen.getByLabelText("Collapse composer"));
fireEvent.click(screen.getByText("visible prompt"));
expect(screen.getByDisplayValue("visible prompt")).toBeInTheDocument();
});
it("uses the video model filter and reruns the selected video node", async () => { it("uses the video model filter and reruns the selected video node", async () => {
useWorkflowStore.setState({ nodes: [videoNode("vid-1", true)] }); useWorkflowStore.setState({ nodes: [videoNode("vid-1", true)] });
render(<GenerationComposer />); render(<GenerationComposer />);

46
src/components/composer/GenerationComposer.tsx

@ -8,6 +8,7 @@ import {
useState, useState,
type ChangeEvent, type ChangeEvent,
type FocusEvent, type FocusEvent,
type MouseEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useReactFlow } from "@xyflow/react"; import { useReactFlow } from "@xyflow/react";
@ -926,6 +927,19 @@ export function GenerationComposer() {
setIsComposerCollapsed((current) => !current); setIsComposerCollapsed((current) => !current);
}, [flushCurrentDraft, isComposerCollapsed]); }, [flushCurrentDraft, isComposerCollapsed]);
const expandComposer = useCallback(() => {
setIsComposerCollapsed(false);
}, []);
const handleCollapsedComposerClick = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("button,a,input,textarea,select,[role='button']")) return;
expandComposer();
},
[expandComposer]
);
const collapsedStatus = useMemo(() => { const collapsedStatus = useMemo(() => {
if (isEaseCurveNode) { if (isEaseCurveNode) {
return (connectedInputs?.videos.length ?? 0) >= 1 return (connectedInputs?.videos.length ?? 0) >= 1
@ -970,36 +984,36 @@ export function GenerationComposer() {
className="hidden" className="hidden"
/> />
{isComposerCollapsed ? ( {isComposerCollapsed ? (
<div className="flex h-16 items-center gap-3 px-4"> <div className="flex h-16 cursor-pointer items-center gap-3 px-4" onClick={handleCollapsedComposerClick}>
<div className="min-w-0 flex-1"> <button
<div className="flex min-w-0 items-center gap-2"> type="button"
onClick={expandComposer}
title={t("composer.expand")}
className="min-w-0 flex-1 rounded-lg text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50"
>
<span className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px] text-neutral-500">{modeLabel}</span> <span className="shrink-0 text-[11px] text-neutral-500">{modeLabel}</span>
<span className="h-3 w-px shrink-0 bg-neutral-700" /> <span className="h-3 w-px shrink-0 bg-neutral-700" />
{isProcessNode ? ( {isProcessNode ? (
<span className="truncate text-sm text-neutral-300">{collapsedStatus}</span> <span className="truncate text-sm text-neutral-300">{collapsedStatus}</span>
) : ( ) : (
<button <span
type="button" className={`min-w-0 truncate text-sm ${canChooseModel ? "text-neutral-100" : "text-neutral-500"}`}
onClick={() => canChooseModel && setModelDialogOpen(true)}
disabled={!canChooseModel}
aria-label={t("composer.selectModel")}
className="min-w-0 truncate rounded-md text-left text-sm text-neutral-100 transition-colors hover:text-cyan-200 disabled:text-neutral-500"
title={t("composer.selectModelTitle", { model: getComposerModelLabel(draft.selectedModel) })}
> >
{getComposerModelLabel(draft.selectedModel)} {getComposerModelLabel(draft.selectedModel)}
</button> </span>
)} )}
</div> </span>
{!isProcessNode && ( {!isProcessNode && (
<div className="mt-1 truncate text-xs text-neutral-500"> <span className="mt-1 block truncate text-xs text-neutral-500">
{collapsedStatus} {collapsedStatus}
</div> </span>
)} )}
</div> </button>
<button <button
type="button" type="button"
onClick={toggleComposerCollapsed} onClick={expandComposer}
aria-label={t("composer.expand")} aria-label={t("composer.expand")}
title={t("composer.expand")} title={t("composer.expand")}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-900/70 text-neutral-300 transition-colors hover:border-neutral-600 hover:text-neutral-100" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-900/70 text-neutral-300 transition-colors hover:border-neutral-600 hover:text-neutral-100"

Loading…
Cancel
Save