From ef063cac3c15203b9a99b8c528b805d6f419d37b Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:24:43 +0000 Subject: [PATCH 1/4] feat(task T01): implement via codex --- dashboard/src/v2/lib/system-api.ts | 20 +++++++++- docs/operations/runbook.md | 2 +- src/server/dashboard-route-registration.ts | 2 + src/services/update-checker-service.ts | 39 +++++++++++++++++++ .../server/update-status-routes.test.ts | 24 ++++++++++++ .../services/update-checker-service.test.ts | 28 +++++++++++++ 6 files changed, 112 insertions(+), 3 deletions(-) diff --git a/dashboard/src/v2/lib/system-api.ts b/dashboard/src/v2/lib/system-api.ts index 91f233d02e..1042ee26cb 100644 --- a/dashboard/src/v2/lib/system-api.ts +++ b/dashboard/src/v2/lib/system-api.ts @@ -1,10 +1,26 @@ import { fetchJson } from "../../lib/api/fetch-json.js"; -export const fetchUpdateStatus = async (): Promise<{ +export type UpdateDownloadTargetKind = "npm" | "electron"; + +export interface UpdateDownloadTarget { + kind: UpdateDownloadTargetKind; + label: string; + url: string; +} + +export interface UpdateDownloadTargets { + npm: UpdateDownloadTarget & { kind: "npm" }; + electron: UpdateDownloadTarget & { kind: "electron" }; +} + +export interface UpdateStatus { currentVersion: string; latestVersion: string | null; updateAvailable: boolean; releaseUrl: string; + downloadTargets: UpdateDownloadTargets; checkedAt: string; error?: string; -}> => fetchJson("/api/system/update-status"); +} + +export const fetchUpdateStatus = async (): Promise => fetchJson("/api/system/update-status"); diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index 80dec21fda..d1b82e820d 100644 --- a/docs/operations/runbook.md +++ b/docs/operations/runbook.md @@ -107,7 +107,7 @@ Checks: - Sprint session-sync activity polling uses the same bounded behavior: a slow or rejected provider activity API logs `Could not fetch activities for session` with `sessionName`, `pageSize`, `concurrency`, `timeoutMs`, `elapsedMs`, `errorName`, and `errorMessage`, then records an empty activity list for that poll only. A genuine empty provider response is not logged as a failure. - Live activity warnings include structured fields such as `sessionName`, `failureCause`, `errorName`, `cacheFallbackState`, `cachedActivityCount`, and `timeoutMs` when applicable. They should not include provider output bodies; inspect provider session logs separately if the cause needs deeper diagnosis. - To validate this surface after cache or timeout changes, run `pnpm run test:backend -- tests/backend/server/activity-cache-service.test.ts`, then `pnpm run test:backend:coverage` to confirm `src/server/activity-cache-service.ts` remains above its 80% line threshold. -- `/api/system/update-status` reports the running Code UX version plus the latest published npm version. It caches the npm lookup briefly, so repeated dashboard refreshes should not hammer the registry, and the dashboard logs a single startup notice when a newer release is available. +- `/api/system/update-status` reports the running Code UX version plus the latest published npm version. It caches the npm lookup briefly, so repeated dashboard refreshes should not hammer the registry, and the dashboard logs a single startup notice when a newer release is available. The response keeps the legacy `releaseUrl` field and also includes typed `downloadTargets.npm` and `downloadTargets.electron` entries so consumers can route npm installs to `npmjs.com` and desktop updates to the official `github.com/codeux-ai/codeux` release tag. Registry failures return `latestVersion: null`, the repository releases fallback URL, and stable official fallback download targets. - The dashboard title bar shows a small "Update available" badge next to the version label whenever `/api/system/update-status` reports a newer published version. If the badge is missing, the check either found no newer release or the lookup failed and was suppressed. - If the dashboard still degrades under load, inspect `runtime.debugLogFileLevel`; file logging defaults to `error` and uses async streams, but sustained log volume is still a useful signal that a hot loop is too noisy. diff --git a/src/server/dashboard-route-registration.ts b/src/server/dashboard-route-registration.ts index 636eb8761c..d2918a371e 100644 --- a/src/server/dashboard-route-registration.ts +++ b/src/server/dashboard-route-registration.ts @@ -1,6 +1,7 @@ import type { Express } from "express"; import type { DashboardDependencies, DashboardServerOptions } from "./dashboard-server.js"; import { CODE_UX_VERSION } from "../shared/config/code-ux-paths.js"; +import { buildUpdateDownloadTargets } from "../services/update-checker-service.js"; import { registerProjectRoutes } from "./project-routes.js"; import { registerSprintRoutes } from "./sprint-routes.js"; @@ -51,6 +52,7 @@ export const createDashboardRouteDependencies = (options: DashboardServerOptions latestVersion: null, updateAvailable: false, releaseUrl: "https://github.com/codeux-ai/codeux/releases", + downloadTargets: buildUpdateDownloadTargets(null), checkedAt: new Date().toISOString(), })), getLocalMcpSetup: routeDependencies.getLocalMcpSetup ?? (() => ({ diff --git a/src/services/update-checker-service.ts b/src/services/update-checker-service.ts index 18260a711f..7c506d6f64 100644 --- a/src/services/update-checker-service.ts +++ b/src/services/update-checker-service.ts @@ -1,10 +1,24 @@ import { CODE_UX_VERSION } from "../shared/config/code-ux-paths.js"; +export type UpdateDownloadTargetKind = "npm" | "electron"; + +export interface UpdateDownloadTarget { + kind: UpdateDownloadTargetKind; + label: string; + url: string; +} + +export interface UpdateDownloadTargets { + npm: UpdateDownloadTarget & { kind: "npm" }; + electron: UpdateDownloadTarget & { kind: "electron" }; +} + export interface UpdateStatus { currentVersion: string; latestVersion: string | null; updateAvailable: boolean; releaseUrl: string; + downloadTargets: UpdateDownloadTargets; checkedAt: string; error?: string; } @@ -12,6 +26,7 @@ export interface UpdateStatus { const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000; const DEFAULT_FETCH_TIMEOUT_MS = 2500; const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/@codeuxai/codeux/latest"; +const CODE_UX_NPM_PACKAGE_URL = "https://www.npmjs.com/package/@codeuxai/codeux"; const CODE_UX_RELEASES_URL = "https://github.com/codeux-ai/codeux/releases"; function parseVersionSegment(segment: string): number { @@ -49,6 +64,28 @@ function buildReleaseUrl(version: string | null): string { return `${CODE_UX_RELEASES_URL}/tag/v${encodeURIComponent(version)}`; } +function buildNpmPackageUrl(version: string | null): string { + if (!version) { + return CODE_UX_NPM_PACKAGE_URL; + } + return `${CODE_UX_NPM_PACKAGE_URL}/v/${encodeURIComponent(version)}`; +} + +export function buildUpdateDownloadTargets(version: string | null): UpdateDownloadTargets { + return { + npm: { + kind: "npm", + label: version ? `npm package @codeuxai/codeux ${version}` : "npm package @codeuxai/codeux", + url: buildNpmPackageUrl(version), + }, + electron: { + kind: "electron", + label: version ? `Code UX desktop release ${version}` : "Code UX desktop releases", + url: buildReleaseUrl(version), + }, + }; +} + export class UpdateCheckerService { private cachedStatus: UpdateStatus | null = null; private cachedAtMs = 0; @@ -99,6 +136,7 @@ export class UpdateCheckerService { latestVersion: normalizedLatestVersion, updateAvailable: compareDottedVersions(normalizedLatestVersion, CODE_UX_VERSION) > 0, releaseUrl: buildReleaseUrl(normalizedLatestVersion), + downloadTargets: buildUpdateDownloadTargets(normalizedLatestVersion), checkedAt, }; @@ -111,6 +149,7 @@ export class UpdateCheckerService { latestVersion: null, updateAvailable: false, releaseUrl: buildReleaseUrl(null), + downloadTargets: buildUpdateDownloadTargets(null), checkedAt, error: controller.signal.aborted ? `Update check timed out after ${this.fetchTimeoutMs}ms.` diff --git a/tests/backend/server/update-status-routes.test.ts b/tests/backend/server/update-status-routes.test.ts index 73d632b19b..6fe8f544d6 100644 --- a/tests/backend/server/update-status-routes.test.ts +++ b/tests/backend/server/update-status-routes.test.ts @@ -10,6 +10,18 @@ describe("update status routes", () => { latestVersion: "0.9.0", updateAvailable: true, releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v0.9.0", + downloadTargets: { + npm: { + kind: "npm", + label: "npm package @codeuxai/codeux 0.9.0", + url: "https://www.npmjs.com/package/@codeuxai/codeux/v/0.9.0", + }, + electron: { + kind: "electron", + label: "Code UX desktop release 0.9.0", + url: "https://github.com/codeux-ai/codeux/releases/tag/v0.9.0", + }, + }, checkedAt: "2026-07-02T00:00:00.000Z", }); @@ -24,6 +36,18 @@ describe("update status routes", () => { latestVersion: "0.9.0", updateAvailable: true, releaseUrl: "https://github.com/codeux-ai/codeux/releases/tag/v0.9.0", + downloadTargets: { + npm: { + kind: "npm", + label: "npm package @codeuxai/codeux 0.9.0", + url: "https://www.npmjs.com/package/@codeuxai/codeux/v/0.9.0", + }, + electron: { + kind: "electron", + label: "Code UX desktop release 0.9.0", + url: "https://github.com/codeux-ai/codeux/releases/tag/v0.9.0", + }, + }, checkedAt: "2026-07-02T00:00:00.000Z", }); expect(getUpdateStatus).toHaveBeenCalledTimes(1); diff --git a/tests/backend/services/update-checker-service.test.ts b/tests/backend/services/update-checker-service.test.ts index 95e9f2fe43..712bf9b40d 100644 --- a/tests/backend/services/update-checker-service.test.ts +++ b/tests/backend/services/update-checker-service.test.ts @@ -22,6 +22,18 @@ describe("UpdateCheckerService", () => { expect(status.latestVersion).toBe("99.0.0"); expect(status.updateAvailable).toBe(true); expect(status.releaseUrl).toBe("https://github.com/codeux-ai/codeux/releases/tag/v99.0.0"); + expect(status.downloadTargets).toEqual({ + npm: { + kind: "npm", + label: "npm package @codeuxai/codeux 99.0.0", + url: "https://www.npmjs.com/package/@codeuxai/codeux/v/99.0.0", + }, + electron: { + kind: "electron", + label: "Code UX desktop release 99.0.0", + url: "https://github.com/codeux-ai/codeux/releases/tag/v99.0.0", + }, + }); expect(status.error).toBeUndefined(); expect(Number.isNaN(Date.parse(status.checkedAt))).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -47,6 +59,10 @@ describe("UpdateCheckerService", () => { expect(first.latestVersion).toBe("0.8.10"); expect(second.latestVersion).toBe("0.8.10"); expect(forced.latestVersion).toBe("0.9.0"); + expect(first.downloadTargets.npm.url).toBe("https://www.npmjs.com/package/@codeuxai/codeux/v/0.8.10"); + expect(second.downloadTargets.electron.url).toBe("https://github.com/codeux-ai/codeux/releases/tag/v0.8.10"); + expect(forced.downloadTargets.npm.url).toBe("https://www.npmjs.com/package/@codeuxai/codeux/v/0.9.0"); + expect(forced.downloadTargets.electron.url).toBe("https://github.com/codeux-ai/codeux/releases/tag/v0.9.0"); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -66,6 +82,18 @@ describe("UpdateCheckerService", () => { expect(status.latestVersion).toBeNull(); expect(status.updateAvailable).toBe(false); expect(status.releaseUrl).toBe("https://github.com/codeux-ai/codeux/releases"); + expect(status.downloadTargets).toEqual({ + npm: { + kind: "npm", + label: "npm package @codeuxai/codeux", + url: "https://www.npmjs.com/package/@codeuxai/codeux", + }, + electron: { + kind: "electron", + label: "Code UX desktop releases", + url: "https://github.com/codeux-ai/codeux/releases", + }, + }); expect(status.error).toContain("HTTP 503"); expect(fetchMock).toHaveBeenCalledTimes(1); }); From a5e27d83895dcb129484ada2b43f03b27e9305fc Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 04:31:55 +0000 Subject: [PATCH 2/4] feat(task T03): implement via codex --- dashboard/src/v2/ChatPage.tsx | 14 +- .../v2/components/chat/ChatMessageBubble.tsx | 7 +- .../chat/InvocationMessageBubble.tsx | 8 +- .../chat/widgets/PlanningRequestWidget.tsx | 125 +++++++- dashboard/src/v2/hooks/use-chat-page-data.ts | 13 + .../src/v2/lib/chat-widget-view-models.ts | 302 +++++++++++++++++- docs-web/content/docs/user-dashboard-chat.mdx | 4 + docs-web/user/dashboard/chat.md | 4 + docs/dashboard/design-system-chat.md | 2 + .../lib/chat-widget-view-models.test.ts | 201 +++++++++++- .../v2/chat-message-bubbles.test.tsx | 142 +++++++- 11 files changed, 797 insertions(+), 25 deletions(-) diff --git a/dashboard/src/v2/ChatPage.tsx b/dashboard/src/v2/ChatPage.tsx index 641564b2f4..f0e1b6cfb2 100644 --- a/dashboard/src/v2/ChatPage.tsx +++ b/dashboard/src/v2/ChatPage.tsx @@ -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"; @@ -126,16 +125,23 @@ export const ChatPage: FunctionComponent = () => { confirmOptions, handleConfirm, handleCancel, + execution, + projectTasks, + 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, + execution, + sprintKeyPrefix, + }), [execution, projectTasks, selectedProject?.id, sprintKeyPrefix]); const handleRestartInvocation = useCallback(async (mode: InvocationRestartMode = "retry_full_prompt") => { if (!selectedInvocation || selectedInvocation.status !== "failed" || restartingInvocation || cancellingInvocationId || resettingUsageLimitInvocationId) { @@ -445,6 +451,7 @@ export const ChatPage: FunctionComponent = () => { allMessages={messages} agentAvatarConfig={preset?.avatarConfig} agentName={preset?.name} + widgetLiveData={widgetLiveData} /> ); })} @@ -806,6 +813,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} /> ); }) diff --git a/dashboard/src/v2/components/chat/ChatMessageBubble.tsx b/dashboard/src/v2/components/chat/ChatMessageBubble.tsx index c4ffd1ae48..46a9c7707a 100644 --- a/dashboard/src/v2/components/chat/ChatMessageBubble.tsx +++ b/dashboard/src/v2/components/chat/ChatMessageBubble.tsx @@ -11,6 +11,7 @@ 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; @@ -18,6 +19,7 @@ export interface ChatMessageBubbleProps { agentAvatarConfig?: AgentAvatarConfig; agentName?: string; animationDelay?: number; + widgetLiveData?: ChatWidgetLiveData; } export const ChatMessageBubble: FunctionComponent = ({ @@ -26,9 +28,10 @@ export const ChatMessageBubble: FunctionComponent = ({ agentAvatarConfig, agentName, animationDelay = 0, + widgetLiveData, }) => { const fromDashboard = message.direction === "dashboard_to_connection"; - const widgetData = getChatWidgetData(message); + const widgetData = getChatWidgetData(message, widgetLiveData); const bubbleRef = useRef(null); const durations = useGsapDurations(); @@ -104,7 +107,7 @@ export const ChatMessageBubble: FunctionComponent = ({ {/* Widget Slot */} {widgetData.type === "planning" && (
- +
)} diff --git a/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx b/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx index 902ffea173..7900a83269 100644 --- a/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx +++ b/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx @@ -12,7 +12,7 @@ import { PlanningRequestWidget } from "./widgets/PlanningRequestWidget.js"; import { ToolCallWidget } from "./widgets/ToolCallWidget.js"; import { ReasoningWidget } from "./widgets/ReasoningWidget.js"; import { ChatAvatar, type AvatarRole } from "./ChatAvatar.js"; -import type { ParsedTurnTokens } from "../../lib/chat-widget-view-models.js"; +import type { ChatWidgetLiveData, ParsedTurnTokens } from "../../lib/chat-widget-view-models.js"; import type { AgentAvatarConfig } from "../../types.js"; const asString = (value: unknown): string | null => (typeof value === "string" ? value : null); @@ -38,17 +38,19 @@ export interface InvocationMessageBubbleProps { message: ExecutionInvocationMessageRecord; agentAvatarConfig?: AgentAvatarConfig | null; agentName?: string | null; + widgetLiveData?: ChatWidgetLiveData; } export const InvocationMessageBubble: FunctionComponent = ({ message, agentAvatarConfig, agentName, + widgetLiveData, }) => { const fromUser = message.role === "user"; const fromTool = message.role === "tool"; const fromSystem = message.role === "system"; - const widgetData = getInvocationWidgetData(message); + const widgetData = getInvocationWidgetData(message, widgetLiveData); const kind = asString(message.metadata?.kind); const reasoningWidgetData = getReasoningWidgetData(message); @@ -175,7 +177,7 @@ export const InvocationMessageBubble: FunctionComponent - + )} diff --git a/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx b/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx index 74bf4cf250..64bd7d4b61 100644 --- a/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx +++ b/dashboard/src/v2/components/chat/widgets/PlanningRequestWidget.tsx @@ -1,18 +1,137 @@ import { type FunctionComponent } from "preact"; +import { AlertTriangle, CheckCircle2, Circle, Clock3, Loader2, PauseCircle, XCircle } from "lucide-preact"; import { ChatWidgetFrame, type ExecutionStatus } from "./ChatWidgetFrame.js"; import { ContainerShip } from "../../ui/PlanningShip.js"; import { ChatRuntimeBadge } from "../ChatRuntimeBadge.js"; +import type { LivePlanningTaskState, LivePlanningWidgetState } from "../../../lib/chat-widget-view-models.js"; export interface PlanningRequestWidgetProps { status: ExecutionStatus; planName: string; isDark?: boolean; + liveStatus?: LivePlanningWidgetState; } +const statusTone: Record = { + queued: "border-slate-300/60 bg-slate-200/45 text-slate-700 dark:border-white/10 dark:bg-white/[0.06] dark:text-slate-300", + running: "border-signal-500/30 bg-signal-500/10 text-signal-700 dark:text-signal-300", + review: "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300", + completed: "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + failed: "border-status-red/30 bg-status-red/10 text-status-red", + blocked: "border-status-amber/35 bg-status-amber/10 text-status-amber", + quota: "border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-300", + unknown: "border-slate-300/60 bg-slate-200/45 text-slate-700 dark:border-white/10 dark:bg-white/[0.06] dark:text-slate-300", +}; + +const TaskStatusIcon: FunctionComponent<{ statusKind: LivePlanningTaskState["statusKind"] }> = ({ statusKind }) => { + switch (statusKind) { + case "completed": + return