From 3fe27b5d01ada6b364a0e779c1a199f8e465e652 Mon Sep 17 00:00:00 2001 From: qyinm Date: Wed, 19 Aug 2026 22:11:00 +0900 Subject: [PATCH 01/19] feat(web): integrate product theme --- apps/web/src/app/(app)/layout.tsx | 2 ++ apps/web/src/app/globals.css | 8 ++++- apps/web/src/app/layout.tsx | 1 + .../components/layout/product-shell.test.tsx | 36 ++++++++++++++----- .../src/components/layout/product-shell.tsx | 10 ++++++ apps/web/src/test/setup.ts | 29 +++++++++++++++ 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 5153fe8..8283a5b 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,3 +1,5 @@ +import "@astryxdesign/core/astryx.css"; + import type { ReactNode } from "react"; import { ProductShell } from "@/components/layout/product-shell"; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a92c29b..05b0b0a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,4 +1,10 @@ -@import 'tailwindcss'; +@layer reset, theme, base, astryx-base, astryx-theme, components, utilities; + +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/preflight.css' layer(base); +@import '@astryxdesign/core/reset.css'; +@import '@astryxdesign/core/tailwind-theme.css'; +@import 'tailwindcss/utilities.css' layer(utilities); @custom-variant dark (&:is(.dark *)); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1869213..57304f4 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -60,6 +60,7 @@ export default function RootLayout({ return ( {children} diff --git a/apps/web/src/components/layout/product-shell.test.tsx b/apps/web/src/components/layout/product-shell.test.tsx index ba460d9..a03978c 100644 --- a/apps/web/src/components/layout/product-shell.test.tsx +++ b/apps/web/src/components/layout/product-shell.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const navigation = vi.hoisted(() => ({ pathname: "/artifacts" })); @@ -10,19 +10,20 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/components/layout/product-sidebar", () => ({ - ProductSidebar: () => null, + ProductSidebar: ({ onThemeChange }: { onThemeChange: (theme: "system" | "light" | "dark") => void }) => ( +
+ + +
+ ), })); import { ProductShell } from "./product-shell"; -describe("ProductShell mobile navigation", () => { +describe("ProductShell", () => { beforeEach(() => { navigation.pathname = "/artifacts"; - vi.stubGlobal("matchMedia", () => ({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })); + document.documentElement.dataset.theme = "light"; }); it("links directly to workspace Settings and marks Artifacts as current", () => { @@ -53,4 +54,23 @@ describe("ProductShell mobile navigation", () => { expect(screen.getByRole("link", { name: "Chat" })).not.toHaveAttribute("aria-current"); expect(screen.getByRole("link", { name: "Artifacts" })).not.toHaveAttribute("aria-current"); }); + + it("keeps the document theme in sync with the product theme", async () => { + const { unmount } = render( + +
Content
+
, + ); + + fireEvent.click(screen.getByRole("button", { name: "Use dark theme" })); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark")); + + fireEvent.click(screen.getByRole("button", { name: "Use light theme" })); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light")); + + fireEvent.click(screen.getByRole("button", { name: "Use dark theme" })); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark")); + unmount(); + expect(document.documentElement.dataset.theme).toBe("light"); + }); }); diff --git a/apps/web/src/components/layout/product-shell.tsx b/apps/web/src/components/layout/product-shell.tsx index 7bca425..6854477 100644 --- a/apps/web/src/components/layout/product-shell.tsx +++ b/apps/web/src/components/layout/product-shell.tsx @@ -38,6 +38,16 @@ export function ProductShell({ children }: { children: ReactNode }) { const darkMode = theme === "dark" || (theme === "system" && systemDark); + useEffect(() => { + document.documentElement.dataset.theme = darkMode ? "dark" : "light"; + }, [darkMode]); + + useEffect(() => { + return () => { + document.documentElement.dataset.theme = "light"; + }; + }, []); + return (
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index 0ecbed3..02e0e64 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -2,4 +2,33 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; import { afterEach } from "vitest"; +if (typeof window !== "undefined" && !window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), + }); +} + +if (typeof globalThis.ResizeObserver === "undefined") { + class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + } + + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: ResizeObserverMock, + }); +} + afterEach(() => cleanup()); From d3c05d38a84c717811d8ffab23d2f2520b1436f9 Mon Sep 17 00:00:00 2001 From: qyinm Date: Wed, 19 Aug 2026 22:11:05 +0900 Subject: [PATCH 02/19] feat(chat): adopt chat composer --- .../src/features/chat/chat-composer.test.tsx | 16 ++- apps/web/src/features/chat/chat-composer.tsx | 125 ++++++++++++------ apps/web/src/features/chat/chat-home.tsx | 21 ++- .../src/features/chat/chat-workspace-utils.ts | 8 +- 4 files changed, 114 insertions(+), 56 deletions(-) diff --git a/apps/web/src/features/chat/chat-composer.test.tsx b/apps/web/src/features/chat/chat-composer.test.tsx index ae19a5a..b886342 100644 --- a/apps/web/src/features/chat/chat-composer.test.tsx +++ b/apps/web/src/features/chat/chat-composer.test.tsx @@ -7,6 +7,11 @@ import { ChatComposer } from "./chat-composer"; const references = [{ id: "source-1", label: "PR #1", available: true }]; +function inputText(element: HTMLElement, value: string) { + element.textContent = value; + fireEvent.input(element); +} + describe("ChatComposer", () => { it("enables send only for a trimmed prompt with a connected source", () => { const onSubmit = vi.fn(); @@ -14,10 +19,13 @@ describe("ChatComposer", () => { const prompt = screen.getByRole("textbox", { name: "Chat message" }); const send = screen.getByRole("button", { name: "Send message" }); + expect(send).toHaveClass("bg-primary", "text-primary-foreground", "dark:bg-[#f4f4f5]"); expect(send).toBeDisabled(); - fireEvent.change(prompt, { target: { value: " " } }); + inputText(prompt, " "); expect(send).toBeDisabled(); - fireEvent.change(prompt, { target: { value: " Write release notes " } }); + fireEvent.keyDown(prompt, { key: "Enter" }); + expect(onSubmit).not.toHaveBeenCalled(); + inputText(prompt, " Write release notes "); expect(send).toBeEnabled(); fireEvent.click(send); @@ -30,12 +38,12 @@ describe("ChatComposer", () => { it("stays disabled without a selected source or while busy", () => { const { unmount } = render(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Write release notes" } }); + inputText(screen.getByRole("textbox"), "Write release notes"); expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); unmount(); render(); - fireEvent.change(screen.getByRole("textbox"), { target: { value: "Write release notes" } }); + inputText(screen.getByRole("textbox"), "Write release notes"); expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); }); }); diff --git a/apps/web/src/features/chat/chat-composer.tsx b/apps/web/src/features/chat/chat-composer.tsx index 73bbf11..d127dd7 100644 --- a/apps/web/src/features/chat/chat-composer.tsx +++ b/apps/web/src/features/chat/chat-composer.tsx @@ -1,7 +1,14 @@ "use client"; -import { ArrowUp } from "lucide-react"; -import { useRef, useState } from "react"; +import { + ChatComposer as AstryxChatComposer, + ChatComposerDrawer, + ChatComposerInput, + ChatSendButton, +} from "@astryxdesign/core/Chat"; +import { Citation } from "@astryxdesign/core/Citation"; +import type { CSSProperties } from "react"; +import { useId, useRef } from "react"; import { cn } from "@/lib/utils"; @@ -10,7 +17,7 @@ type ChatComposerProps = { variant?: "center" | "dock"; id?: string; placeholder?: string; - references?: { id: string; label: string; available: boolean; groupId?: string }[]; + references?: { id: string; label: string; available: boolean; groupId?: string; url?: string }[]; busy?: boolean; }; @@ -22,55 +29,85 @@ export function ChatComposer({ references = [], busy = false, }: ChatComposerProps) { - const [message, setMessage] = useState(""); const submittingRef = useRef(false); - const [selectedReferenceIds] = useState([]); - const canSubmit = !busy && Boolean(message.trim()) && references.some((reference) => reference.available); + const hasConnectedSource = references.some((reference) => reference.available); + + function submit(value: string) { + if (submittingRef.current) return; + + submittingRef.current = true; + onSubmit(value, []); + queueMicrotask(() => { + submittingRef.current = false; + }); + } return ( -
{ - event.preventDefault(); - if (!canSubmit || submittingRef.current) return; - - submittingRef.current = true; - onSubmit(message.trim(), selectedReferenceIds); - setMessage(""); - queueMicrotask(() => { submittingRef.current = false; }); - }} > -
-