From eea39280d487e4d398c66de4b3b7209cd76cb946 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 2 Oct 2026 19:16:30 -0400 Subject: [PATCH 1/3] feat(web): morph composer and panel action icons --- apps/web/package.json | 1 + ...omposerPrimaryActions.interaction.test.tsx | 148 ++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 36 ++--- .../components/chat/PanelLayoutControls.tsx | 24 +-- pnpm-lock.yaml | 95 ++++++++++- 5 files changed, 269 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPrimaryActions.interaction.test.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 5209d306ca04..17d81584fb0c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "jsonc-parser": "3.3.1", "jszip": "3.10.1", "lucide-react": "^0.564.0", + "morphicons": "^1.7.1", "react": "19.2.6", "react-dom": "19.2.6", "react-markdown": "^10.1.0", diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.interaction.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.interaction.test.tsx new file mode 100644 index 000000000000..3ed3cbbfbbd5 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPrimaryActions.interaction.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom + +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/hooks/useSettings", () => ({ + useEnvironmentIdentificationMode: () => "none", +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; + +let root: Root; +let container: HTMLDivElement; + +async function renderActions( + overrides: Partial> = {}, +) { + await act(() => { + root.render( +
event.preventDefault()}> + {}} + onInterrupt={() => {}} + onImplementPlanInNewThread={() => {}} + {...overrides} + /> + , + ); + }); +} + +function sendButton() { + return container.querySelector('button[type="submit"]')!; +} + +async function pressModifier(key: "Meta" | "Control", held: boolean) { + await act(() => { + window.dispatchEvent( + new KeyboardEvent(held ? "keydown" : "keyup", { + key, + metaKey: key === "Meta" && held, + ctrlKey: key === "Control" && held, + }), + ); + }); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("matchMedia", () => ({ matches: true })); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("composer queue/steer transition", () => { + it.each(["Meta", "Control"] as const)( + "morphs the queue icon into the steer icon while %s is held, then restores queue on release", + async (key) => { + await renderActions(); + const button = sendButton(); + const svg = button.querySelector("svg"); + const queuePath = svg?.querySelector("path")?.getAttribute("d"); + expect(button.getAttribute("aria-label")).toBe("Queue message"); + + await pressModifier(key, true); + expect(button.getAttribute("aria-label")).toBe("Steer message"); + expect(button.querySelector("svg")).toBe(svg); + expect(svg?.querySelector("path")?.getAttribute("d")).not.toBe(queuePath); + await pressModifier(key, false); + expect(button.getAttribute("aria-label")).toBe("Queue message"); + expect(svg?.querySelector("path")?.getAttribute("d")).toBe(queuePath); + }, + ); + + it.each(["blur", "paste"])("restores queue after %s without a modifier keyup", async (event) => { + await renderActions(); + await pressModifier("Meta", true); + expect(sendButton().getAttribute("aria-label")).toBe("Steer message"); + await act(() => { + window.dispatchEvent(new Event(event)); + }); + expect(sendButton().getAttribute("aria-label")).toBe("Queue message"); + }); + + it("reverses the transition for the steer preference and keeps idle sends unchanged", async () => { + await renderActions({ followUpBehavior: "steer" }); + expect(sendButton().getAttribute("aria-label")).toBe("Steer message"); + const steerPath = sendButton().querySelector("path")?.getAttribute("d"); + await pressModifier("Meta", true); + expect(sendButton().getAttribute("aria-label")).toBe("Queue message"); + await renderActions({ isRunning: false, followUpBehavior: "steer" }); + expect(sendButton().getAttribute("aria-label")).toBe("Submit message"); + expect(sendButton().querySelector("path")?.getAttribute("d")).not.toBe(steerPath); + }); + + it("settles the animated morph and stops requesting frames", async () => { + vi.stubGlobal("matchMedia", () => ({ matches: false })); + vi.useFakeTimers(); + const requestFrame = vi.fn((callback: FrameRequestCallback) => + setTimeout(() => callback(performance.now()), 16), + ); + vi.stubGlobal("requestAnimationFrame", requestFrame); + vi.stubGlobal("cancelAnimationFrame", clearTimeout); + await renderActions(); + const path = sendButton().querySelector("path")!; + const queuePath = path.getAttribute("d"); + expect(requestFrame).not.toHaveBeenCalled(); + await pressModifier("Meta", true); + await act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(path.getAttribute("d")).not.toBe(queuePath); + expect(requestFrame).toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + await pressModifier("Meta", false); + await act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(path.getAttribute("d")).toBe(queuePath); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 76156ffda49c..673890e67166 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,12 +1,7 @@ import { memo, type MouseEventHandler, type PointerEventHandler } from "react"; -import { - CheckIcon, - ChevronDownIcon, - ChevronLeftIcon, - CornerUpRightIcon, - ListPlusIcon, - PlayIcon, -} from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, PlayIcon } from "lucide-react"; +import { fitIcon } from "morphicons"; +import { MorphIcon } from "morphicons/react"; import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { useShortcutModifierState } from "../../shortcutModifierState"; @@ -83,6 +78,11 @@ const preventPointerFocus: PointerEventHandler = (event) => { event.preventDefault(); }; +// The original send arrow uses a 14px grid; morph endpoints share Lucide's 24px grid. +const sendIcon = fitIcon("M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5", 14); +const queueIcon = "M16 5H3M11 12H3M16 19H3M18 9v6M21 12h-6"; +const steerIcon = "m15 14 5-5-5-5M4 20v-7a4 4 0 0 1 4-4h12"; + export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ compact, pendingAction, @@ -315,20 +315,14 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({