Skip to content
Merged
31 changes: 28 additions & 3 deletions dashboard/src/v2/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { EmptyState } from "./components/ui/EmptyState.js";
import { MessageCircle } from "lucide-preact";
import { ChatMessageBubble } from "./components/chat/ChatMessageBubble.js";
import { useChatPageData } from "./hooks/use-chat-page-data.js";
import { useProjectEffectiveSettings } from "./hooks/use-project-effective-settings.js";
import { formatInvocationPurpose, formatInvocationDuration, InvocationContextChips } from "./components/chat/invocation-display.js";
import { InvocationMessageBubble } from "./components/chat/InvocationMessageBubble.js";
import { InvocationRoutingWidget } from "./components/chat/widgets/InvocationRoutingWidget.js";
Expand Down Expand Up @@ -126,16 +125,40 @@ export const ChatPage: FunctionComponent = () => {
confirmOptions,
handleConfirm,
handleCancel,
execution,
executionLoading,
executionLoaded,
projectTasks,
projectTasksLoading,
projectTasksLoaded,
sprintKeyPrefix,
} = useChatPageData({ composerRef, messagesRef });

const effectiveSettings = useProjectEffectiveSettings(selectedProject?.id ?? null);
const sprintKeyPrefix = effectiveSettings.data?.settings?.git?.sprintKeyPrefix || "SPR";
const projectThreads = useMemo(() => threads.filter((thread) => thread.scope === "project"), [threads]);
const displayedInvocationTotal = invocationTotalCount ?? invocations.length;
const runningInvocationCount = useMemo(
() => invocations.filter((invocation) => invocation.status === "running" || invocation.id.startsWith("optimistic:")).length,
[invocations],
);
const widgetLiveData = useMemo(() => ({
projectId: selectedProject?.id ?? null,
projectTasks,
projectTasksLoading,
projectTasksLoaded,
execution,
executionLoading,
executionLoaded,
sprintKeyPrefix,
}), [
execution,
executionLoaded,
executionLoading,
projectTasks,
projectTasksLoaded,
projectTasksLoading,
selectedProject?.id,
sprintKeyPrefix,
]);

const handleRestartInvocation = useCallback(async (mode: InvocationRestartMode = "retry_full_prompt") => {
if (!selectedInvocation || selectedInvocation.status !== "failed" || restartingInvocation || cancellingInvocationId || resettingUsageLimitInvocationId) {
Expand Down Expand Up @@ -445,6 +468,7 @@ export const ChatPage: FunctionComponent = () => {
allMessages={messages}
agentAvatarConfig={preset?.avatarConfig}
agentName={preset?.name}
widgetLiveData={widgetLiveData}
/>
);
})}
Expand Down Expand Up @@ -806,6 +830,7 @@ export const ChatPage: FunctionComponent = () => {
message={message}
agentAvatarConfig={message.role === "assistant" ? (selectedAgentPreset?.avatarConfig ?? null) : null}
agentName={message.role === "assistant" ? (selectedAgentPreset?.name ?? null) : null}
widgetLiveData={widgetLiveData}
/>
);
})
Expand Down
63 changes: 46 additions & 17 deletions dashboard/src/v2/components/TitleBar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { FunctionComponent } from "preact";
import { useEffect, useState } from "preact/hooks";
import { Copy, ExternalLink, Minus, Square, X } from "lucide-preact";
import { Copy, Download, Minus, Square, X } from "lucide-preact";
import { RobotLogo } from "./brand/RobotLogo.js";
import { fetchUpdateStatus } from "../lib/system-api.js";
import { fetchUpdateStatus, type UpdateStatus } from "../lib/system-api.js";

declare const __APP_VERSION__: string;

type Platform = "darwin" | "win32" | "linux" | "other";
type TitleBarUpdateStatus = Omit<UpdateStatus, "downloadTargets"> & {
downloadTargets?: UpdateStatus["downloadTargets"];
};

const resolvePlatform = (raw?: string): Platform => {
if (raw === "darwin" || raw === "win32" || raw === "linux") return raw;
Expand All @@ -17,21 +20,49 @@ interface TitleBarProps {
appearanceVariant?: "default" | "translucent";
}

type UpdateStatus = {
currentVersion: string;
latestVersion: string | null;
updateAvailable: boolean;
releaseUrl: string;
checkedAt: string;
error?: string;
type UpdateDownloadAction = {
href: string;
ariaLabel: string;
title: string;
};

export const resolveUpdateDownloadAction = (
status: TitleBarUpdateStatus | null,
isDesktopSession: boolean,
): UpdateDownloadAction | null => {
if (!status?.updateAvailable || status.error) {
return null;
}

const target = isDesktopSession
? status.downloadTargets?.electron
: status.downloadTargets?.npm;
const href = target?.url || status.releaseUrl;
if (!href) {
return null;
}

const versionLabel = status.latestVersion ? `Code UX ${status.latestVersion}` : "Code UX";
const targetLabel = target?.kind === "electron"
? "desktop release download page"
: target?.kind === "npm"
? "npm package download page"
: "release download page";
const label = `Open ${versionLabel} ${targetLabel} in your browser`;

return {
href,
ariaLabel: label,
title: label,
};
};

export const TitleBar: FunctionComponent<TitleBarProps> = ({ appearanceVariant = "translucent" }) => {
const desktop = typeof window !== "undefined" ? window.codeUxDesktop : undefined;
const windowApi = desktop?.window;
const [platform, setPlatform] = useState<Platform>(() => resolvePlatform(desktop?.platform));
const [isMaximized, setIsMaximized] = useState(false);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
const [updateStatus, setUpdateStatus] = useState<TitleBarUpdateStatus | null>(null);

useEffect(() => {
if (!windowApi) return;
Expand Down Expand Up @@ -82,9 +113,7 @@ export const TitleBar: FunctionComponent<TitleBarProps> = ({ appearanceVariant =
if (!windowApi) return null;

const isMac = platform === "darwin";
const updateAction = updateStatus?.updateAvailable && !updateStatus.error && updateStatus.releaseUrl
? updateStatus
: null;
const updateAction = resolveUpdateDownloadAction(updateStatus, Boolean(desktop));

const controls = isMac ? null : (
<div className="flex items-stretch h-full titlebar-no-drag">
Expand Down Expand Up @@ -147,14 +176,14 @@ export const TitleBar: FunctionComponent<TitleBarProps> = ({ appearanceVariant =
v{__APP_VERSION__}
{updateAction ? (
<a
href={updateAction.releaseUrl}
href={updateAction.href}
target="_blank"
rel="noreferrer"
aria-label={updateAction.latestVersion ? `Open Code UX ${updateAction.latestVersion} release` : "Open Code UX releases"}
title={updateAction.latestVersion ? `Open Code UX ${updateAction.latestVersion} release` : "Open Code UX releases"}
aria-label={updateAction.ariaLabel}
title={updateAction.title}
className="titlebar-no-drag ml-2 inline-flex h-5 items-center gap-1 rounded-full border border-amber-500/20 bg-amber-500/10 px-1.5 text-[9px] font-bold uppercase tracking-wider text-amber-600 transition-colors hover:border-amber-500/35 hover:bg-amber-500/15 hover:text-amber-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/45 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-400 dark:hover:border-amber-300/35 dark:hover:bg-amber-400/15 dark:hover:text-amber-300"
>
<ExternalLink aria-hidden="true" className="h-3 w-3" strokeWidth={2} />
<Download aria-hidden="true" className="h-3 w-3" strokeWidth={2} />
Update available
</a>
) : null}
Expand Down
97 changes: 72 additions & 25 deletions dashboard/src/v2/components/__tests__/TitleBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/preact";
import { TitleBar } from "../TitleBar.js";
import { resolveUpdateDownloadAction, TitleBar } from "../TitleBar.js";
import type { UpdateStatus } from "../../lib/system-api.js";
import "@testing-library/jest-dom/vitest";

const fetchUpdateStatusMock = vi.hoisted(() => vi.fn());
Expand All @@ -13,6 +14,27 @@ vi.mock("../../lib/system-api.js", () => ({
fetchUpdateStatus: fetchUpdateStatusMock,
}));

const createUpdateStatus = (overrides: Partial<UpdateStatus> = {}): UpdateStatus => ({
currentVersion: "1.0.0",
latestVersion: "1.2.0",
updateAvailable: true,
releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0",
downloadTargets: {
npm: {
kind: "npm",
label: "npm package @codeuxai/codeux 1.2.0",
url: "https://www.npmjs.com/package/@codeuxai/codeux/v/1.2.0",
},
electron: {
kind: "electron",
label: "Code UX desktop release 1.2.0",
url: "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0",
},
},
checkedAt: "2026-07-02T00:00:00.000Z",
...overrides,
});

describe("TitleBar", () => {
beforeEach(() => {
fetchUpdateStatusMock.mockReset();
Expand All @@ -37,24 +59,55 @@ describe("TitleBar", () => {
vi.unstubAllGlobals();
});

it("renders an update badge when a newer version is available", async () => {
fetchUpdateStatusMock.mockResolvedValue({
currentVersion: "1.0.0",
latestVersion: "1.2.0",
updateAvailable: true,
releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0",
checkedAt: "2026-07-02T00:00:00.000Z",
});
it("renders an update badge with the Electron download target in desktop sessions", async () => {
fetchUpdateStatusMock.mockResolvedValue(createUpdateStatus());

render(<TitleBar />);

const updateLink = await screen.findByRole("link", { name: "Open Code UX 1.2.0 release" });
const updateLink = await screen.findByRole("link", {
name: "Open Code UX 1.2.0 desktop release download page in your browser",
});

expect(updateLink).toHaveTextContent("Update available");
expect(updateLink).toHaveAttribute("href", "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0");
expect(updateLink).toHaveAttribute("target", "_blank");
expect(updateLink).toHaveAttribute("rel", "noreferrer");
expect(updateLink).toHaveAttribute("title", "Open Code UX 1.2.0 release");
expect(updateLink).toHaveAttribute(
"title",
"Open Code UX 1.2.0 desktop release download page in your browser",
);
expect(updateLink.querySelector("svg.lucide-download")).not.toBeNull();
});

it("resolves the npm download target for non-Electron dashboard sessions", () => {
expect(resolveUpdateDownloadAction(createUpdateStatus(), false)).toEqual({
href: "https://www.npmjs.com/package/@codeuxai/codeux/v/1.2.0",
ariaLabel: "Open Code UX 1.2.0 npm package download page in your browser",
title: "Open Code UX 1.2.0 npm package download page in your browser",
});

delete window.codeUxDesktop;

const { container } = render(<TitleBar />);

expect(container).toBeEmptyDOMElement();
expect(fetchUpdateStatusMock).not.toHaveBeenCalled();
});

it("falls back to the release URL when download targets are absent", () => {
const statusWithoutTargets = {
currentVersion: "1.0.0",
latestVersion: "1.2.0",
updateAvailable: true,
releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0",
checkedAt: "2026-07-02T00:00:00.000Z",
};

expect(resolveUpdateDownloadAction(statusWithoutTargets, true)).toEqual({
href: "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0",
ariaLabel: "Open Code UX 1.2.0 release download page in your browser",
title: "Open Code UX 1.2.0 release download page in your browser",
});
});

it("renders no badge when the update check fails", async () => {
Expand All @@ -67,17 +120,15 @@ describe("TitleBar", () => {
});

expect(screen.queryByText("Update available")).toBeNull();
expect(screen.queryByRole("link", { name: /release/i })).toBeNull();
expect(screen.queryByRole("link", { name: /download page/i })).toBeNull();
});

it("renders no update control when no update is available", async () => {
fetchUpdateStatusMock.mockResolvedValue({
currentVersion: "1.0.0",
fetchUpdateStatusMock.mockResolvedValue(createUpdateStatus({
latestVersion: "1.0.0",
updateAvailable: false,
releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v1.0.0",
checkedAt: "2026-07-02T00:00:00.000Z",
});
}));

render(<TitleBar />);

Expand All @@ -86,18 +137,13 @@ describe("TitleBar", () => {
});

expect(screen.queryByText("Update available")).toBeNull();
expect(screen.queryByRole("link", { name: /release/i })).toBeNull();
expect(screen.queryByRole("link", { name: /download page/i })).toBeNull();
});

it("renders no update control when update status contains an error", async () => {
fetchUpdateStatusMock.mockResolvedValue({
currentVersion: "1.0.0",
latestVersion: "1.2.0",
updateAvailable: true,
releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v1.2.0",
checkedAt: "2026-07-02T00:00:00.000Z",
fetchUpdateStatusMock.mockResolvedValue(createUpdateStatus({
error: "registry unavailable",
});
}));

render(<TitleBar />);

Expand All @@ -106,6 +152,7 @@ describe("TitleBar", () => {
});

expect(screen.queryByText("Update available")).toBeNull();
expect(screen.queryByRole("link", { name: /release/i })).toBeNull();
expect(screen.queryByRole("link", { name: /download page/i })).toBeNull();
expect(resolveUpdateDownloadAction(createUpdateStatus({ error: "registry unavailable" }), true)).toBeNull();
});
});
7 changes: 5 additions & 2 deletions dashboard/src/v2/components/chat/ChatMessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { ChatAvatar, type AvatarRole } from "./ChatAvatar.js";
import { resolveDisplayDeliveryStatus } from "../../hooks/use-chat-thread-data.js";
import { useGsapDurations } from "../../lib/motion/constants.js";
import { useReducedMotion } from "../../hooks/use-reduced-motion.js";
import type { ChatWidgetLiveData } from "../../lib/chat-widget-view-models.js";

export interface ChatMessageBubbleProps {
message: ChatMessageRecord;
allMessages?: ChatMessageRecord[];
agentAvatarConfig?: AgentAvatarConfig;
agentName?: string;
animationDelay?: number;
widgetLiveData?: ChatWidgetLiveData;
}

export const ChatMessageBubble: FunctionComponent<ChatMessageBubbleProps> = ({
Expand All @@ -26,9 +28,10 @@ export const ChatMessageBubble: FunctionComponent<ChatMessageBubbleProps> = ({
agentAvatarConfig,
agentName,
animationDelay = 0,
widgetLiveData,
}) => {
const fromDashboard = message.direction === "dashboard_to_connection";
const widgetData = getChatWidgetData(message);
const widgetData = getChatWidgetData(message, widgetLiveData);

const bubbleRef = useRef<HTMLDivElement>(null);
const durations = useGsapDurations();
Expand Down Expand Up @@ -104,7 +107,7 @@ export const ChatMessageBubble: FunctionComponent<ChatMessageBubbleProps> = ({
{/* Widget Slot */}
{widgetData.type === "planning" && (
<div className="mt-4 border-t border-white/5 pt-4">
<PlanningRequestWidget status={widgetData.status} planName={widgetData.planName} />
<PlanningRequestWidget status={widgetData.status} planName={widgetData.planName} liveStatus={widgetData.liveStatus} />
</div>
)}

Expand Down
Loading