Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ if (process.env.LIGHTCODE_CDP_PORT) {
app.commandLine.appendSwitch("remote-debugging-port", process.env.LIGHTCODE_CDP_PORT);
}

// Windows HDR can make DWM acrylic visibly change opacity when Chromium starts
// compositing image content in the display color space. Keep Chromium in sRGB so
// acrylic stays translucent without breathing as image planes appear/disappear.
if (process.platform === "win32") {
app.commandLine.appendSwitch("force-color-profile", "srgb");
}

const chromeLikeUserAgent = buildChromeLikeUserAgent(app.userAgentFallback);
app.userAgentFallback = chromeLikeUserAgent;

Expand Down
2 changes: 2 additions & 0 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { clearRuntimeItemStoreSelectorCacheForThread } from "./components/thread

import { useAppHydration } from "@/renderer/hooks/useAppHydration";
import { AppProvider } from "./components/ui/provider";
import { ImageLightboxHost } from "./components/composer";
import { MainView } from "@/renderer/views/MainView/MainView";
import { CommandPalette } from "@/renderer/commands/CommandPalette";
import {
Expand Down Expand Up @@ -286,6 +287,7 @@ export function App() {
<AppProvider contentReady>
<MainView storeHydrated={storeHydrated} loadT0={loadT0} />
<CommandPalette />
<ImageLightboxHost />
</AppProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface BranchSelectorProps {
moveBranchCopyIgnoredPatterns?: string[];
popoverPlacement?: "top" | "bottom";
forceHideLabel?: boolean;
collapseTier?: number;
iconOnly?: boolean;
/** Hide the leading branch/fork glyph on the trigger (e.g. when a sibling control already shows it). */
hideTriggerIcon?: boolean;
Expand All @@ -75,11 +76,13 @@ export function BranchSelector(props: BranchSelectorProps) {
moveBranchCopyIgnoredPatterns,
popoverPlacement = "top",
forceHideLabel = false,
collapseTier,
iconOnly = false,
hideTriggerIcon = false,
compact = false,
} = props;
const triggerIconSize = compact ? "size-3" : "size-3.5";
const hideLabelOnWrap = collapseTier !== undefined;
const { t } = useLingui();

const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -291,9 +294,10 @@ export function BranchSelector(props: BranchSelectorProps) {
) : null}
{!iconOnly && (
<span
data-collapse-tier={collapseTier}
className={
forceHideLabel
? "lightcode-composer-label-hideable truncate is-hidden"
hideLabelOnWrap
? `lightcode-composer-label-hideable truncate${forceHideLabel ? " is-hidden" : ""}`
: "truncate"
}
>
Expand All @@ -302,9 +306,10 @@ export function BranchSelector(props: BranchSelectorProps) {
)}
{!iconOnly && (
<ChevronDown
data-collapse-tier={collapseTier}
className={
forceHideLabel
? `lightcode-composer-label-hideable ${triggerIconSize} text-muted is-hidden`
hideLabelOnWrap
? `lightcode-composer-label-hideable ${triggerIconSize} text-muted${forceHideLabel ? " is-hidden" : ""}`
: `${triggerIconSize} text-muted`
}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface EffortContextMenuProps {
isDisabled?: boolean;
hideLabelOnWrap?: boolean;
forceHideLabel?: boolean;
collapseTier?: number;
openSignal?: number;
onOpenChange?: (open: boolean) => void;
}
Expand All @@ -39,6 +40,7 @@ export function EffortContextMenu(props: EffortContextMenuProps) {
isDisabled,
hideLabelOnWrap,
forceHideLabel = false,
collapseTier,
openSignal,
onOpenChange,
} = props;
Expand Down Expand Up @@ -98,6 +100,7 @@ export function EffortContextMenu(props: EffortContextMenuProps) {
>
{icon}
<span
data-collapse-tier={collapseTier}
className={
hideLabelOnWrap
? `lightcode-composer-label-hideable truncate${forceHideLabel ? " is-hidden" : ""}`
Expand All @@ -107,6 +110,7 @@ export function EffortContextMenu(props: EffortContextMenuProps) {
{triggerLabel}
</span>
<ChevronDown
data-collapse-tier={collapseTier}
className={
hideLabelOnWrap
? `lightcode-composer-label-hideable size-3.5 text-muted${forceHideLabel ? " is-hidden" : ""}`
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/components/common/OptionMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface OptionMenuProps {
buttonVariant?: ButtonProps["variant"];
hideLabelOnWrap?: boolean;
forceHideLabel?: boolean;
collapseTier?: number;
iconOnly?: boolean;
tooltip?: string | undefined;
onOpenChange?: (open: boolean) => void;
Expand All @@ -39,6 +40,7 @@ export function OptionMenu(props: OptionMenuProps) {
buttonVariant = "secondary",
hideLabelOnWrap = false,
forceHideLabel = false,
collapseTier,
iconOnly = false,
tooltip,
onOpenChange,
Expand All @@ -52,7 +54,7 @@ export function OptionMenu(props: OptionMenuProps) {
);
const currentValue =
normalizedOptions.find((option) => option.id === value)?.label || value || resolvedPlaceholder;
const effectiveTooltip = tooltip ?? (iconOnly ? currentValue : undefined);
const effectiveTooltip = tooltip ?? (hideLabelOnWrap || iconOnly ? currentValue : undefined);
const buttonProps = className ? { className } : {};

const button = (
Expand All @@ -66,6 +68,7 @@ export function OptionMenu(props: OptionMenuProps) {
{icon}
{!iconOnly && (
<span
data-collapse-tier={collapseTier}
className={
hideLabelOnWrap
? `lightcode-composer-label-hideable truncate${forceHideLabel ? " is-hidden" : ""}`
Expand All @@ -77,6 +80,7 @@ export function OptionMenu(props: OptionMenuProps) {
)}
{!iconOnly && (
<ChevronDown
data-collapse-tier={collapseTier}
className={
hideLabelOnWrap
? `lightcode-composer-label-hideable size-3.5 text-muted${forceHideLabel ? " is-hidden" : ""}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface ProviderModelMenuProps {
isDisabled?: boolean;
hideLabelOnWrap?: boolean;
forceHideLabel?: boolean;
collapseTier?: number;
openSignal?: number;
onChange: (next: {
agentKind: string;
Expand Down Expand Up @@ -219,6 +220,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
isDisabled,
hideLabelOnWrap,
forceHideLabel = false,
collapseTier,
openSignal,
onChange,
onOpenChange,
Expand Down Expand Up @@ -360,6 +362,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
className="size-3.5 shrink-0"
/>
<span
data-collapse-tier={collapseTier}
className={
hideLabelOnWrap
? `lightcode-composer-label-hideable flex min-w-0 flex-col items-start justify-center gap-0.5${forceHideLabel ? " is-hidden" : ""}`
Expand All @@ -376,6 +379,7 @@ export function ProviderModelMenu(props: ProviderModelMenuProps) {
) : null}
</span>
<ChevronDown
data-collapse-tier={collapseTier}
className={
hideLabelOnWrap
? `lightcode-composer-label-hideable size-3.5 text-muted${forceHideLabel ? " is-hidden" : ""}`
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/components/composer/AttachmentBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe("AttachmentBar", () => {
"lightcode-attachment-bar",
"lightcode-attachment-bar--inset",
);
expect(screen.getByAltText("screenshot.png").getAttribute("loading")).toBeNull();
expect(screen.getByText("screenshot.png")).toBeInTheDocument();

fireEvent.click(screen.getByRole("button"));
Expand All @@ -37,6 +38,25 @@ describe("AttachmentBar", () => {
);
});

it("renders eager fixed-size image previews for inline message attachments", () => {
render(
<AttachmentBar
attachments={[
{
id: "image-1",
path: "/tmp/screenshot.png",
name: "screenshot.png",
mimeType: "image/png",
isImage: true,
},
]}
imagesAsPreview
/>,
);

expect(screen.getByAltText("screenshot.png").getAttribute("loading")).toBeNull();
});

it("renders flush attachment bars for inline message attachments", () => {
const { container } = render(
<AttachmentBar
Expand Down
15 changes: 13 additions & 2 deletions src/renderer/components/composer/AttachmentBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function AttachmentChip(props: {
className="lightcode-attachment-chip__thumb"
src={toLocalFileUrl(att.path)}
alt={att.name}
decoding="async"
draggable={false}
/>
) : (
Expand Down Expand Up @@ -150,20 +151,30 @@ function ImagePreview(props: {
}) {
const { t } = useLingui();
const { attachment: att, onPreviewImage } = props;
const img = <img src={toLocalFileUrl(att.path)} alt={att.name} draggable={false} />;
const img = (
<img src={toLocalFileUrl(att.path)} alt={att.name} decoding="async" draggable={false} />
);
if (onPreviewImage) {
return (
<button
type="button"
className="lightcode-attachment-image-preview"
data-lightcode-attachment-image-preview="true"
onClick={() => onPreviewImage(att)}
aria-label={t`Preview ${att.name}`}
>
{img}
</button>
);
}
return <span className="lightcode-attachment-image-preview">{img}</span>;
return (
<span
className="lightcode-attachment-image-preview"
data-lightcode-attachment-image-preview="true"
>
{img}
</span>
);
}

export function AttachmentBar(props: {
Expand Down
84 changes: 67 additions & 17 deletions src/renderer/components/composer/ImageLightbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { memo, useEffect, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useLingui } from "@lingui/react/macro";
Expand All @@ -13,33 +13,82 @@ export interface LightboxImage {
alt?: string;
}

/**
* Attachment-backed lightbox used by the composer surfaces. Resolves each
* attachment's local path to a renderable URL and defers to
* {@link ImageLightboxView}.
*/
export function ImageLightbox(props: {
images: Attachment[];
type LightboxState = {
images: readonly LightboxImage[];
initialIndex: number;
onClose: () => void;
}) {
const images: LightboxImage[] = props.images.map((img) => ({
src: toLocalFileUrl(img.path),
alt: img.name,
}));
return (
<ImageLightboxView images={images} initialIndex={props.initialIndex} onClose={props.onClose} />
nonce: number;
};

let lightboxState: LightboxState | null = null;
let lightboxNonce = 0;
const lightboxListeners = new Set<() => void>();

function emitLightboxChange() {
for (const listener of lightboxListeners) listener();
}

function subscribeLightbox(listener: () => void): () => void {
lightboxListeners.add(listener);
return () => {
lightboxListeners.delete(listener);
};
}

function getLightboxSnapshot(): LightboxState | null {
return lightboxState;
}

export function openImageLightbox(images: readonly LightboxImage[], initialIndex: number): void {
if (images.length === 0) return;
lightboxState = {
images: [...images],
initialIndex: Math.min(Math.max(0, initialIndex), images.length - 1),
nonce: ++lightboxNonce,
};
emitLightboxChange();
}

export function openAttachmentLightbox(
attachments: readonly Attachment[],
initialIndex: number,
): void {
openImageLightbox(
attachments.map((img) => ({
src: toLocalFileUrl(img.path),
alt: img.name,
})),
initialIndex,
);
}

export function closeImageLightbox(): void {
if (lightboxState === null) return;
lightboxState = null;
emitLightboxChange();
}

export const ImageLightboxHost = memo(function ImageLightboxHost() {
const state = useSyncExternalStore(subscribeLightbox, getLightboxSnapshot, getLightboxSnapshot);
useEffect(() => closeImageLightbox, []);
if (!state) return null;
return (
<ImageLightboxView
key={state.nonce}
images={state.images}
initialIndex={state.initialIndex}
onClose={closeImageLightbox}
/>
);
});

/**
* Source-agnostic fullscreen image viewer. Accepts already-resolved image URLs
* (`data:`, `lightcode-local://`, remote) so it can be reused for chat-generated
* images as well as composer attachments. Supports keyboard nav and prev/next
* chrome for multi-image galleries; a single image renders without that chrome.
*/
export function ImageLightboxView(props: {
images: LightboxImage[];
images: readonly LightboxImage[];
initialIndex: number;
onClose: () => void;
}) {
Expand Down Expand Up @@ -106,6 +155,7 @@ export function ImageLightboxView(props: {
src={current.src}
alt={current.alt ?? ""}
onClick={(e) => e.stopPropagation()}
decoding="async"
draggable={false}
/>

Expand Down
8 changes: 7 additions & 1 deletion src/renderer/components/composer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ export { MentionInput, type MentionInputHandle } from "./MentionInput";
export { AttachmentBar, BrowserChip } from "./AttachmentBar";
export { ComposerAddMenu } from "./ComposerAddMenu";
export { VoiceInputButton, type VoiceInputHandle } from "./VoiceInputButton";
export { ImageLightbox, ImageLightboxView, type LightboxImage } from "./ImageLightbox";
export {
ImageLightboxHost,
ImageLightboxView,
openAttachmentLightbox,
openImageLightbox,
type LightboxImage,
} from "./ImageLightbox";
export { useAttachments, type Attachment } from "./useAttachments";
export { toLocalFileUrl } from "@/shared/promptContent";
Loading