Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/components/SidebarStageBackdrop.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveEnvironmentIdentificationPillLabel,
resolveSidebarStageBackdropVariant,
StageBackdropArt,
StageBackdropButtonArt,
} from "./SidebarStageBackdrop";

describe("SidebarStageBackdrop", () => {
Expand All @@ -28,7 +29,7 @@ describe("SidebarStageBackdrop", () => {
const markup = renderToStaticMarkup(
<>
<StageBackdropArt variant={variant} />
<StageBackdropArt variant={variant} />
<StageBackdropButtonArt variant={variant} />
</>,
);
const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]);
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/SidebarStageBackdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar
return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
}

export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) {
return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />;
}

const NIGHTLY_STARS: ReadonlyArray<{
cx: number;
cy: number;
Expand Down Expand Up @@ -93,7 +97,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [
{ x: 246, y: 26 },
];

function NightlySkyArt() {
function NightlySkyArt({ compact = false }: { compact?: boolean }) {
const idPrefix = useId().replaceAll(":", "");
const skyId = `${idPrefix}-stage-night-sky`;
const glowId = `${idPrefix}-stage-night-glow`;
Expand All @@ -107,7 +111,7 @@ function NightlySkyArt() {
className="h-full w-full"
fill="none"
preserveAspectRatio="xMinYMin slice"
viewBox={STAGE_BACKDROP_VIEW_BOX}
viewBox={compact ? "0 0 96 96" : STAGE_BACKDROP_VIEW_BOX}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
Expand Down Expand Up @@ -191,7 +195,7 @@ function NightlySkyArt() {
);
}

function DevBlueprintArt() {
function DevBlueprintArt({ compact = false }: { compact?: boolean }) {
const idPrefix = useId().replaceAll(":", "");
const paperId = `${idPrefix}-stage-bp-paper`;
const glowId = `${idPrefix}-stage-bp-glow`;
Expand All @@ -208,7 +212,7 @@ function DevBlueprintArt() {
className="stage-blueprint h-full w-full"
fill="none"
preserveAspectRatio="xMinYMin slice"
viewBox={STAGE_BACKDROP_VIEW_BOX}
viewBox={compact ? "0 0 96 96" : STAGE_BACKDROP_VIEW_BOX}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
Expand Down
77 changes: 74 additions & 3 deletions apps/web/src/components/chat/ComposerPrimaryActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";

const stageMock = vi.hoisted(() => ({
artworkEnabled: false,
variant: null as "dev" | "nightly" | null,
}));

vi.mock("~/hooks/useSettings", () => ({
useEnvironmentIdentificationMode: () => "none",
useDefaultEnvironmentArtworkEnabled: () => stageMock.artworkEnabled,
}));
vi.mock("../SidebarStageBackdrop", () => ({
StageBackdropButtonArt: () => null,
useSidebarStageBackdropVariant: () => null,
StageBackdropButtonArt: ({ variant }: { variant: "dev" | "nightly" }) => `stage-${variant}`,
useSidebarStageBackdropVariant: (enabled: boolean) => (enabled ? stageMock.variant : null),
}));

import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
Expand Down Expand Up @@ -60,6 +65,35 @@ function renderStandaloneStop() {
);
}

function renderSendAction(
input: Partial<{
hasSendableContent: boolean;
isConnecting: boolean;
isSendBusy: boolean;
isEnvironmentUnavailable: boolean;
sendDisabledReason: string | null;
}> = {},
) {
return renderToStaticMarkup(
createElement(ComposerPrimaryActions, {
compact: true,
pendingAction: null,
isRunning: false,
showPlanFollowUpPrompt: false,
promptHasText: false,
isSendBusy: input.isSendBusy ?? false,
sendDisabledReason: input.sendDisabledReason ?? null,
isConnecting: input.isConnecting ?? false,
isEnvironmentUnavailable: input.isEnvironmentUnavailable ?? false,
isPreparingWorktree: false,
hasSendableContent: input.hasSendableContent ?? true,
onPreviousPendingQuestion: () => {},
onInterrupt: () => {},
onImplementPlanInNewThread: () => {},
}),
);
}

describe("formatPendingPrimaryActionLabel", () => {
it("returns 'Submitting...' while responding", () => {
expect(
Expand Down Expand Up @@ -164,4 +198,41 @@ describe("ComposerPrimaryActions", () => {
expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");
expect(renderStandaloneStop()).not.toContain("sm:size-7");
});

it.each(["dev", "nightly"] as const)(
"uses %s artwork on the send action in artwork mode",
(variant) => {
stageMock.artworkEnabled = true;
stageMock.variant = variant;

const markup = renderSendAction();

expect(markup).toContain(`stage-${variant}`);
expect(markup).toContain("bg-transparent");
expect(markup).toContain('aria-label="Send message"');
},
);

it("keeps the standard send action when default-theme artwork is disabled", () => {
stageMock.artworkEnabled = false;
stageMock.variant = "dev";

const markup = renderSendAction();

expect(markup).not.toContain("stage-dev");
expect(markup).toContain("bg-message-action");
});

it("preserves busy and disabled labels with artwork", () => {
stageMock.artworkEnabled = true;
stageMock.variant = "nightly";

expect(renderSendAction({ isSendBusy: true })).toContain('aria-label="Sending"');
expect(renderSendAction({ isEnvironmentUnavailable: true })).toContain(
'aria-label="Environment disconnected"',
);
expect(renderSendAction({ sendDisabledReason: "Choose a provider" })).toContain(
'aria-label="Choose a provider"',
);
});
});
13 changes: 12 additions & 1 deletion apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { memo, type PointerEventHandler } from "react";
import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
import { useDefaultEnvironmentArtworkEnabled } from "~/hooks/useSettings";
import { cn } from "~/lib/utils";
import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
import { Button } from "../ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { Spinner } from "../ui/spinner";
Expand Down Expand Up @@ -73,7 +75,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
const pointerFocusProps = preserveComposerFocusOnPointerDown
? { onPointerDown: preventPointerFocus }
: undefined;
const defaultEnvironmentArtworkEnabled = useDefaultEnvironmentArtworkEnabled();
const isSendDisabled = sendDisabledReason !== null;
const stageBackdropVariant = useSidebarStageBackdropVariant(defaultEnvironmentArtworkEnabled);

const renderStopGenerationButton = (insidePendingAction: boolean) => (
<button
Expand Down Expand Up @@ -213,7 +217,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
type="submit"
className={cn(
"relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full text-message-action-foreground shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8",
"bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover",
stageBackdropVariant
? "bg-transparent enabled:shadow-black/24 enabled:hover:brightness-110"
: "bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover",
)}
{...pointerFocusProps}
disabled={
Expand All @@ -237,6 +243,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
: "Send message"
}
>
{stageBackdropVariant ? (
<span className="absolute inset-0 -z-10" aria-hidden="true">
<StageBackdropButtonArt variant={stageBackdropVariant} />
</span>
) : null}
{isConnecting || isSendBusy ? (
<Spinner className="size-3.5" aria-hidden="true" />
) : (
Expand Down
29 changes: 23 additions & 6 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,10 @@ export function resolveEnvironmentIdentificationMode(input: {
: input.mode;
}

export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
const settingsHydrated = useClientSettingsHydrated();
const mode = useClientSettingsValue().environmentIdentificationMode;
function useEnvironmentIdentificationThemeState(): {
paletteThemeActive: boolean;
paletteThemeAllowsArtwork: boolean;
} {
const { resolvedTheme, theme, themeHalves } = useTheme();
const previewSidebarArtwork = useSyncExternalStore(
subscribeToThemePreview,
Expand All @@ -254,15 +255,31 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod
);
const activeTheme = resolveThemeHalf(theme, themeHalves, resolvedTheme);
const activeThemeDefinition = getThemeDefinition(activeTheme);
return resolveEnvironmentIdentificationMode({
mode,
settingsHydrated,
return {
paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
paletteThemeAllowsArtwork:
previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true,
};
}

export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMode {
const settingsHydrated = useClientSettingsHydrated();
const mode = useClientSettingsValue().environmentIdentificationMode;
return resolveEnvironmentIdentificationMode({
mode,
settingsHydrated,
...useEnvironmentIdentificationThemeState(),
});
}

/** Whether fixed-color artwork is safe to use outside the theme-aware sidebar. */
export function useDefaultEnvironmentArtworkEnabled(): boolean {
const settingsHydrated = useClientSettingsHydrated();
const mode = useClientSettingsValue().environmentIdentificationMode;
const { paletteThemeActive } = useEnvironmentIdentificationThemeState();
return settingsHydrated && mode === "artwork" && !paletteThemeActive;
}

/**
* Whether the legacy sidebar (Settings → General → Legacy features) replaces
* the default one.
Expand Down
Loading