diff --git a/apps/desktop/src/initialBackendWindowOpen.test.ts b/apps/desktop/src/initialBackendWindowOpen.test.ts index 8711e1c9..c5c99c86 100644 --- a/apps/desktop/src/initialBackendWindowOpen.test.ts +++ b/apps/desktop/src/initialBackendWindowOpen.test.ts @@ -22,9 +22,9 @@ function createOptions( setReadinessInFlight: vi.fn((promise) => { readinessInFlight = promise; }), - waitForBackendWindowReady: vi.fn< - InitialBackendWindowOpenOptions["waitForBackendWindowReady"] - >(async () => "listening"), + waitForBackendWindowReady: vi.fn( + async () => "listening", + ), writeLog: vi.fn(), isReadinessAborted: vi.fn(() => false), formatErrorMessage: vi.fn((error) => (error instanceof Error ? error.message : String(error))), diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ab5b4574..9356dc59 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -26,6 +26,10 @@ import { import type { FileFilter, IpcMainEvent, MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopPetOverlayDragStartInput, + DesktopPetOverlayMoveDelta, + DesktopPetOverlayPointerInteractionInput, + DesktopPetOverlayState, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -69,6 +73,18 @@ import { import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; import { DesktopBrowserManager } from "./browserManager"; import { BROWSER_IPC_CHANNELS, registerBrowserIpcHandlers, sendBrowserState } from "./browserIpc"; +import { + DesktopPetOverlayController, + PET_OVERLAY_DRAG_END_CHANNEL, + PET_OVERLAY_DRAG_MOVE_CHANNEL, + PET_OVERLAY_DRAG_START_CHANNEL, + PET_OVERLAY_CLOSE_CHANNEL, + PET_OVERLAY_HIDE_CHANNEL, + PET_OVERLAY_MOVED_CHANNEL, + PET_OVERLAY_MOVE_BY_CHANNEL, + PET_OVERLAY_POINTER_INTERACTION_CHANNEL, + PET_OVERLAY_SET_STATE_CHANNEL, +} from "./petOverlay"; import { BrowserUsePipeServer, DPCODE_BROWSER_USE_PIPE_ENV, @@ -156,6 +172,9 @@ let unreadBackgroundNotificationCount = 0; let browserPerfInterval: ReturnType | null = null; const browserManager = new DesktopBrowserManager(); let browserUsePipeServer: BrowserUsePipeServer | null = null; +let petOverlayController: DesktopPetOverlayController | null = null; +let mainWindowHiddenForPetOverlay = false; +let petOverlayShowTimer: ReturnType | null = null; let configuredGitHubUpdateSource: ReturnType = null; let configuredGitHubUpdateToken = ""; @@ -864,6 +883,10 @@ function configureApplicationMenu(): void { accelerator: "CmdOrCtrl+Shift+B", click: () => dispatchMenuAction("toggle-browser"), }, + { + label: "Show Pet", + click: () => dispatchMenuAction("show-pet"), + }, { type: "separator" }, { role: "reload" }, { role: "forceReload" }, @@ -1010,6 +1033,72 @@ function showDesktopNotification(input: { return true; } +function resolvePetOverlayAssetUrl(rawUrl: string): string { + const trimmedUrl = rawUrl.trim(); + if (/^(https?:|data:|t3:)/i.test(trimmedUrl)) { + return trimmedUrl; + } + if (trimmedUrl.startsWith("/codex-pets") && backendHttpUrl.length > 0) { + return `${backendHttpUrl}${trimmedUrl}`; + } + + try { + const baseUrl = mainWindow?.webContents.getURL() || backendHttpUrl || "t3://app/"; + return new URL(trimmedUrl, baseUrl).toString(); + } catch { + return trimmedUrl; + } +} + +function getPetOverlayController(): DesktopPetOverlayController { + if (!petOverlayController) { + petOverlayController = new DesktopPetOverlayController({ + preloadPath: Path.join(__dirname, "preload.js"), + resolveAssetUrl: resolvePetOverlayAssetUrl, + onMoved: (position) => { + mainWindow?.webContents.send(PET_OVERLAY_MOVED_CHANNEL, position); + }, + }); + } + return petOverlayController; +} + +function clearPetOverlayShowTimer(): void { + if (!petOverlayShowTimer) return; + clearTimeout(petOverlayShowTimer); + petOverlayShowTimer = null; +} + +function showPetOverlayAfterBackgroundTransition(delayMs = 80): void { + if (isQuitting) return; + clearPetOverlayShowTimer(); + petOverlayShowTimer = setTimeout(() => { + petOverlayShowTimer = null; + if (isQuitting || mainWindow?.isFocused()) return; + void petOverlayController?.showLastState(); + }, delayMs); +} + +function isDevToolsWindow(window: BrowserWindow): boolean { + return window.webContents.getURL().startsWith("devtools://"); +} + +function keepPetOverlayInteractionDetachedFromAppFocus(focusedWindow: BrowserWindow): boolean { + if (isQuitting || !petOverlayController?.isCursorOverOverlay()) { + return false; + } + + focusedWindow.blur(); + if (isDevelopment && isDevToolsWindow(focusedWindow)) { + focusedWindow.hide(); + } + if (mainWindowHiddenForPetOverlay && mainWindow?.isVisible()) { + mainWindow.hide(); + } + void petOverlayController.showLastState(); + return true; +} + /** * Resolve the Electron userData directory path. * @@ -1803,6 +1892,50 @@ function registerIpcHandlers(): void { ...(typeof input?.threadId === "string" ? { threadId: input.threadId } : {}), }), ); + + ipcMain.removeHandler(PET_OVERLAY_SET_STATE_CHANNEL); + ipcMain.handle(PET_OVERLAY_SET_STATE_CHANNEL, async (_event, input: unknown) => { + await getPetOverlayController().setState(input as DesktopPetOverlayState); + }); + + ipcMain.removeHandler(PET_OVERLAY_HIDE_CHANNEL); + ipcMain.handle(PET_OVERLAY_HIDE_CHANNEL, async () => { + getPetOverlayController().hide(); + }); + + ipcMain.removeHandler(PET_OVERLAY_CLOSE_CHANNEL); + ipcMain.handle(PET_OVERLAY_CLOSE_CHANNEL, async () => { + getPetOverlayController().close(); + mainWindow?.webContents.send(MENU_ACTION_CHANNEL, "close-pet"); + }); + + ipcMain.removeHandler(PET_OVERLAY_MOVE_BY_CHANNEL); + ipcMain.handle(PET_OVERLAY_MOVE_BY_CHANNEL, async (_event, input: unknown) => { + getPetOverlayController().moveBy(input as DesktopPetOverlayMoveDelta); + }); + + ipcMain.removeHandler(PET_OVERLAY_DRAG_START_CHANNEL); + ipcMain.handle(PET_OVERLAY_DRAG_START_CHANNEL, async (_event, input: unknown) => { + getPetOverlayController().startDrag(input as DesktopPetOverlayDragStartInput); + }); + + ipcMain.removeHandler(PET_OVERLAY_DRAG_MOVE_CHANNEL); + ipcMain.handle(PET_OVERLAY_DRAG_MOVE_CHANNEL, async () => { + getPetOverlayController().moveDrag(); + }); + + ipcMain.removeHandler(PET_OVERLAY_DRAG_END_CHANNEL); + ipcMain.handle(PET_OVERLAY_DRAG_END_CHANNEL, async () => { + getPetOverlayController().endDrag(); + }); + + ipcMain.removeHandler(PET_OVERLAY_POINTER_INTERACTION_CHANNEL); + ipcMain.handle(PET_OVERLAY_POINTER_INTERACTION_CHANNEL, async (_event, input: unknown) => { + getPetOverlayController().setPointerInteraction( + input as DesktopPetOverlayPointerInteractionInput, + ); + }); + registerDesktopVoiceTranscriptionHandler(); startBrowserPerformanceLogging(); void ensureBrowserUsePipeServer().catch((error) => { @@ -1899,6 +2032,50 @@ function createWindow(): BrowserWindow { window.once("ready-to-show", () => { window.show(); }); + window.on("hide", () => { + if (!isQuitting) { + mainWindowHiddenForPetOverlay = true; + showPetOverlayAfterBackgroundTransition(); + } + }); + window.on("minimize", () => { + if (!isQuitting) { + mainWindowHiddenForPetOverlay = true; + showPetOverlayAfterBackgroundTransition(); + } + }); + window.on("blur", () => { + if (!isQuitting) { + showPetOverlayAfterBackgroundTransition(0); + } + }); + window.on("show", () => { + if ( + !isQuitting && + mainWindowHiddenForPetOverlay && + petOverlayController?.isCursorOverOverlay() + ) { + window.hide(); + void petOverlayController.showLastState(); + return; + } + mainWindowHiddenForPetOverlay = false; + clearPetOverlayShowTimer(); + petOverlayController?.hide(); + }); + window.on("restore", () => { + mainWindowHiddenForPetOverlay = false; + clearPetOverlayShowTimer(); + petOverlayController?.hide(); + }); + window.on("focus", () => { + if (keepPetOverlayInteractionDetachedFromAppFocus(window)) { + return; + } + mainWindowHiddenForPetOverlay = false; + clearPetOverlayShowTimer(); + petOverlayController?.hide(); + }); if (isDevelopment) { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); @@ -1911,6 +2088,8 @@ function createWindow(): BrowserWindow { if (mainWindow === window) { mainWindow = null; } + petOverlayController?.dispose(); + petOverlayController = null; browserManager.setWindow(null); }); @@ -2013,9 +2192,12 @@ app.on("before-quit", () => { clearUpdateBackgroundBlurTimer(); clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); + clearPetOverlayShowTimer(); void browserUsePipeServer?.dispose().finally(() => { browserUsePipeServer = null; }); + petOverlayController?.dispose(); + petOverlayController = null; cancelBackendReadinessWait(); stopBackend(); browserManager.dispose(); @@ -2038,13 +2220,26 @@ if (hasSingleInstanceLock) { app.on("browser-window-blur", () => { markDesktopAppBackgrounded(); + showPetOverlayAfterBackgroundTransition(0); }); - app.on("browser-window-focus", () => { + app.on("browser-window-focus", (_event, focusedWindow) => { + if (keepPetOverlayInteractionDetachedFromAppFocus(focusedWindow)) { + return; + } + clearPetOverlayShowTimer(); handleDesktopAppForegrounded(); }); + app.on("hide", () => { + mainWindowHiddenForPetOverlay = true; + showPetOverlayAfterBackgroundTransition(120); + }); + app.on("activate", () => { + if (petOverlayController?.isCursorOverOverlay()) { + return; + } handleDesktopAppForegrounded(); if (BrowserWindow.getAllWindows().length === 0) { if (!isDevelopment) { diff --git a/apps/desktop/src/petOverlay.ts b/apps/desktop/src/petOverlay.ts new file mode 100644 index 00000000..78923b71 --- /dev/null +++ b/apps/desktop/src/petOverlay.ts @@ -0,0 +1,678 @@ +// FILE: petOverlay.ts +// Purpose: Owns the transparent always-on-top Electron pet overlay window. +// Layer: Desktop main process window runtime +// Exports: DesktopPetOverlayController for renderer-driven pet state and dragging + +import { BrowserWindow, screen } from "electron"; +import type { + DesktopPetOverlayDragStartInput, + DesktopPetOverlayMoveDelta, + DesktopPetOverlayPointerInteractionInput, + DesktopPetOverlayState, +} from "@t3tools/contracts"; + +export const PET_OVERLAY_SET_STATE_CHANNEL = "desktop:pet-overlay-set-state"; +export const PET_OVERLAY_HIDE_CHANNEL = "desktop:pet-overlay-hide"; +export const PET_OVERLAY_CLOSE_CHANNEL = "desktop:pet-overlay-close"; +export const PET_OVERLAY_MOVE_BY_CHANNEL = "desktop:pet-overlay-move-by"; +export const PET_OVERLAY_MOVED_CHANNEL = "desktop:pet-overlay-moved"; +export const PET_OVERLAY_DRAG_START_CHANNEL = "desktop:pet-overlay-drag-start"; +export const PET_OVERLAY_DRAG_MOVE_CHANNEL = "desktop:pet-overlay-drag-move"; +export const PET_OVERLAY_DRAG_END_CHANNEL = "desktop:pet-overlay-drag-end"; +export const PET_OVERLAY_POINTER_INTERACTION_CHANNEL = "desktop:pet-overlay-pointer-interaction"; + +type ResolveAssetUrl = (url: string) => string; +type OnMoved = (position: { x: number; y: number }) => void; + +interface OverlayDragState { + pointerAnchorX: number; + pointerAnchorY: number; + hasMoved: boolean; +} + +interface PetOverlayLayout { + windowWidth: number; + windowHeight: number; + petLeft: number; +} + +const MAX_PET_WINDOW_SIZE = 320; +const MIN_PET_WINDOW_SIZE = 16; +// Tiny vertical padding so the soft ground shadow under the pet isn't clipped at the window edge. +const PET_GROUND_PADDING = 8; + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function finiteIntegerInRange(value: unknown, min: number, max: number): number | null { + const number = finiteNumber(value); + if (number === null) return null; + return Math.min(max, Math.max(min, Math.round(number))); +} + +function normalizePetState( + input: unknown, + resolveAssetUrl: ResolveAssetUrl, +): DesktopPetOverlayState | null { + if (typeof input !== "object" || input === null) { + return null; + } + + const rawState = input as Partial; + + const spritesheetUrl = + typeof rawState.spritesheetUrl === "string" ? resolveAssetUrl(rawState.spritesheetUrl) : ""; + const displayName = typeof rawState.displayName === "string" ? rawState.displayName : "Codex pet"; + const description = typeof rawState.description === "string" ? rawState.description : ""; + const animation = typeof rawState.animation === "string" ? rawState.animation : "idle"; + const activity = + typeof rawState.activity === "object" && + rawState.activity !== null && + (rawState.activity.kind === "input-needed" || + rawState.activity.kind === "working" || + rawState.activity.kind === "connecting") && + typeof rawState.activity.label === "string" && + typeof rawState.activity.title === "string" + ? { + kind: rawState.activity.kind, + label: rawState.activity.label.slice(0, 80), + title: rawState.activity.title.slice(0, 120), + } + : null; + const width = finiteIntegerInRange(rawState.width, MIN_PET_WINDOW_SIZE, MAX_PET_WINDOW_SIZE); + const height = finiteIntegerInRange(rawState.height, MIN_PET_WINDOW_SIZE, MAX_PET_WINDOW_SIZE); + const columns = finiteIntegerInRange(rawState.columns, 1, 32); + const rows = finiteIntegerInRange(rawState.rows, 1, 32); + const row = finiteIntegerInRange(rawState.row, 0, 31); + const frames = finiteIntegerInRange(rawState.frames, 1, 32); + const durationMs = finiteIntegerInRange(rawState.durationMs, 0, 5_000); + const x = finiteIntegerInRange(rawState.x, -100_000, 100_000); + const y = finiteIntegerInRange(rawState.y, -100_000, 100_000); + + if (!spritesheetUrl || width === null || height === null || columns === null || rows === null) { + return null; + } + if (row === null || frames === null || durationMs === null || x === null || y === null) { + return null; + } + + return { + visible: rawState.visible === true, + spritesheetUrl, + displayName, + description, + animation, + activity, + row, + frames, + durationMs, + width, + height, + columns, + rows, + x, + y, + }; +} + +function resolveOverlayLayout(state: DesktopPetOverlayState): PetOverlayLayout { + // Desktop overlay shows the bare pet sprite — no dock, no boundaries, just the floating character. + return { + windowWidth: state.width, + windowHeight: state.height + PET_GROUND_PADDING, + petLeft: 0, + }; +} + +function buildPetOverlayHtml(): string { + return ` + + + + + + + +
+ + +
+ + +`; +} + +export class DesktopPetOverlayController { + private window: BrowserWindow | null = null; + private loadPromise: Promise | null = null; + private lastState: DesktopPetOverlayState | null = null; + private lastEmittedPosition: { x: number; y: number } | null = null; + private dragState: OverlayDragState | null = null; + private pointerInteractive = true; + private mousePassthroughEnabled = false; + + constructor( + private readonly input: { + preloadPath: string; + resolveAssetUrl: ResolveAssetUrl; + onMoved: OnMoved; + }, + ) {} + + async setState(input: unknown): Promise { + const state = normalizePetState(input, this.input.resolveAssetUrl); + if (!state || !state.visible) { + if (state) { + this.lastState = state; + } + this.hide(); + return; + } + + this.lastState = state; + await this.showState(state); + } + + async showLastState(): Promise { + if (!this.lastState) return; + await this.showState({ ...this.lastState, visible: true }); + } + + private async showState(state: DesktopPetOverlayState): Promise { + const window = this.ensureWindow(); + const layout = resolveOverlayLayout(state); + const wasVisible = window.isVisible(); + const nextBounds = { + x: state.x - layout.petLeft, + y: state.y, + width: layout.windowWidth, + height: layout.windowHeight, + }; + const currentBounds = window.getBounds(); + this.lastEmittedPosition = { x: state.x, y: state.y }; + if ( + currentBounds.x !== nextBounds.x || + currentBounds.y !== nextBounds.y || + currentBounds.width !== nextBounds.width || + currentBounds.height !== nextBounds.height + ) { + window.setBounds(nextBounds); + } + if (!wasVisible) { + window.setAlwaysOnTop(true, "floating", 1); + this.makeVisibleOnEveryWorkspace(window); + this.pointerInteractive = true; + this.applyPointerInteractivityPolicy(); + window.moveTop(); + window.showInactive(); + } + + await this.waitForLoad(); + if (window.isDestroyed()) return; + await window.webContents + .executeJavaScript(`window.__setPetOverlayState(${JSON.stringify(state)})`, true) + .catch(() => undefined); + } + + hide(): void { + if (this.window && !this.window.isDestroyed()) { + this.window.hide(); + } + } + + close(): void { + this.lastState = this.lastState ? { ...this.lastState, visible: false } : null; + this.hide(); + } + + moveBy(input: DesktopPetOverlayMoveDelta | null | undefined): void { + const window = this.window; + if (!window || window.isDestroyed()) return; + if (typeof input !== "object" || input === null) return; + const dx = finiteIntegerInRange(input.dx, -2_000, 2_000) ?? 0; + const dy = finiteIntegerInRange(input.dy, -2_000, 2_000) ?? 0; + if (dx === 0 && dy === 0) return; + const bounds = window.getBounds(); + window.setPosition(bounds.x + dx, bounds.y + dy); + } + + startDrag(input: DesktopPetOverlayDragStartInput | null | undefined): void { + const window = this.window; + if (!window || window.isDestroyed()) return; + if (typeof input !== "object" || input === null) return; + const pointerWindowX = finiteIntegerInRange( + input.pointerWindowX, + -MAX_PET_WINDOW_SIZE, + MAX_PET_WINDOW_SIZE, + ); + const pointerWindowY = finiteIntegerInRange( + input.pointerWindowY, + -MAX_PET_WINDOW_SIZE, + MAX_PET_WINDOW_SIZE, + ); + if (pointerWindowX === null || pointerWindowY === null) return; + this.dragState = { + pointerAnchorX: pointerWindowX, + pointerAnchorY: pointerWindowY, + hasMoved: false, + }; + this.pointerInteractive = true; + this.applyPointerInteractivityPolicy(); + window.moveTop(); + } + + moveDrag(): void { + const dragState = this.dragState; + if (!dragState) return; + dragState.hasMoved = true; + this.moveDragToCurrentCursor(dragState); + } + + endDrag(): void { + const dragState = this.dragState; + if (dragState?.hasMoved) { + this.moveDragToCurrentCursor(dragState); + } + this.dragState = null; + this.applyPointerInteractivityPolicy(); + } + + setPointerInteraction(input: DesktopPetOverlayPointerInteractionInput | null | undefined): void { + if (typeof input !== "object" || input === null) return; + const nextInteractive = input.interactive === true; + if (this.pointerInteractive === nextInteractive) return; + this.pointerInteractive = nextInteractive; + this.applyPointerInteractivityPolicy(); + } + + isCursorOverOverlay(): boolean { + const window = this.window; + if (!window || window.isDestroyed() || !window.isVisible()) return false; + const cursor = screen.getCursorScreenPoint(); + const bounds = window.getBounds(); + return ( + cursor.x >= bounds.x && + cursor.x <= bounds.x + bounds.width && + cursor.y >= bounds.y && + cursor.y <= bounds.y + bounds.height + ); + } + + dispose(): void { + this.dragState = null; + this.pointerInteractive = true; + this.mousePassthroughEnabled = false; + if (this.window && !this.window.isDestroyed()) { + this.window.close(); + } + this.window = null; + this.loadPromise = null; + } + + private ensureWindow(): BrowserWindow { + if (this.window && !this.window.isDestroyed()) { + return this.window; + } + + const window = new BrowserWindow({ + width: 96, + height: 104, + show: false, + frame: false, + transparent: true, + hasShadow: false, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + focusable: false, + acceptFirstMouse: true, + title: "Codex Pet", + backgroundColor: "#00000000", + webPreferences: { + preload: this.input.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + window.setMenu(null); + window.setAlwaysOnTop(true, "floating", 1); + this.makeVisibleOnEveryWorkspace(window); + window.on("move", () => { + const bounds = window.getBounds(); + const state = this.lastState; + const petLeft = state ? resolveOverlayLayout(state).petLeft : 0; + this.emitMoved({ x: bounds.x + petLeft, y: bounds.y }); + }); + window.on("closed", () => { + if (this.window === window) { + this.window = null; + this.loadPromise = null; + this.dragState = null; + } + }); + + this.window = window; + this.loadPromise = new Promise((resolve) => { + window.webContents.once("did-finish-load", () => resolve()); + }); + void window.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(buildPetOverlayHtml())}`, + ); + return window; + } + + private makeVisibleOnEveryWorkspace(window: BrowserWindow): void { + if (process.platform === "darwin") { + window.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true, + } as Electron.VisibleOnAllWorkspacesOptions); + return; + } + window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + + private moveDragToCurrentCursor(dragState: OverlayDragState): void { + const window = this.window; + if (!window || window.isDestroyed()) return; + + // Follow the OS cursor from the main process, matching Codex's overlay drag model. + const cursor = screen.getCursorScreenPoint(); + const nextX = Math.round(cursor.x - dragState.pointerAnchorX); + const nextY = Math.round(cursor.y - dragState.pointerAnchorY); + const bounds = window.getBounds(); + if (bounds.x === nextX && bounds.y === nextY) return; + window.setPosition(nextX, nextY); + } + + private emitMoved(position: { x: number; y: number }): void { + if (this.lastEmittedPosition?.x === position.x && this.lastEmittedPosition?.y === position.y) { + return; + } + this.lastEmittedPosition = position; + this.input.onMoved(position); + } + + private applyPointerInteractivityPolicy(): void { + const window = this.window; + if (!window || window.isDestroyed()) return; + + const shouldPassThrough = !this.pointerInteractive && this.dragState === null; + if (this.mousePassthroughEnabled === shouldPassThrough) return; + this.mousePassthroughEnabled = shouldPassThrough; + if (shouldPassThrough) { + window.setIgnoreMouseEvents(true, { forward: true }); + return; + } + window.setIgnoreMouseEvents(false); + } + + private async waitForLoad(): Promise { + await this.loadPromise; + } +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d662fd23..04b162e0 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,6 +6,17 @@ import { normalizeDesktopWsUrl, resolveDesktopWsUrlFromEnv, } from "./desktopWsBridge"; +import { + PET_OVERLAY_DRAG_END_CHANNEL, + PET_OVERLAY_DRAG_MOVE_CHANNEL, + PET_OVERLAY_DRAG_START_CHANNEL, + PET_OVERLAY_CLOSE_CHANNEL, + PET_OVERLAY_HIDE_CHANNEL, + PET_OVERLAY_MOVED_CHANNEL, + PET_OVERLAY_MOVE_BY_CHANNEL, + PET_OVERLAY_POINTER_INTERACTION_CHANNEL, + PET_OVERLAY_SET_STATE_CHANNEL, +} from "./petOverlay"; import { SERVER_TRANSCRIBE_VOICE_CHANNEL } from "./voiceTranscription"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; @@ -75,6 +86,30 @@ contextBridge.exposeInMainWorld("desktopBridge", { isSupported: () => ipcRenderer.invoke(NOTIFICATIONS_IS_SUPPORTED_CHANNEL), show: (input) => ipcRenderer.invoke(NOTIFICATIONS_SHOW_CHANNEL, input), }, + petOverlay: { + setState: (input) => ipcRenderer.invoke(PET_OVERLAY_SET_STATE_CHANNEL, input), + hide: () => ipcRenderer.invoke(PET_OVERLAY_HIDE_CHANNEL), + close: () => ipcRenderer.invoke(PET_OVERLAY_CLOSE_CHANNEL), + moveBy: (input) => ipcRenderer.invoke(PET_OVERLAY_MOVE_BY_CHANNEL, input), + dragStart: (input) => ipcRenderer.invoke(PET_OVERLAY_DRAG_START_CHANNEL, input), + dragMove: () => ipcRenderer.invoke(PET_OVERLAY_DRAG_MOVE_CHANNEL), + dragEnd: () => ipcRenderer.invoke(PET_OVERLAY_DRAG_END_CHANNEL), + setPointerInteraction: (input) => + ipcRenderer.invoke(PET_OVERLAY_POINTER_INTERACTION_CHANNEL, input), + onMoved: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, position: unknown) => { + if (typeof position !== "object" || position === null) return; + const maybePosition = position as { x?: unknown; y?: unknown }; + if (typeof maybePosition.x !== "number" || typeof maybePosition.y !== "number") return; + listener({ x: maybePosition.x, y: maybePosition.y }); + }; + + ipcRenderer.on(PET_OVERLAY_MOVED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(PET_OVERLAY_MOVED_CHANNEL, wrappedListener); + }; + }, + }, server: { transcribeVoice: (input) => ipcRenderer.invoke(SERVER_TRANSCRIBE_VOICE_CHANNEL, input), }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index d67b791f..4606811d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -929,12 +929,10 @@ describe("OrchestrationEngine", () => { const readModel = await system.run(engine.getReadModel()); expect( - readModel.projects.find((project) => project.id === asProjectId("project-stale")) - ?.deletedAt, + readModel.projects.find((project) => project.id === asProjectId("project-stale"))?.deletedAt, ).toBe(createdAt); expect( - readModel.projects.find((project) => project.id === asProjectId("project-readd")) - ?.deletedAt, + readModel.projects.find((project) => project.id === asProjectId("project-readd"))?.deletedAt, ).toBeNull(); await system.dispose(); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 9b42ddb7..0ada0807 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1494,9 +1494,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } const requestId = - extractActivityRequestId(activity.payload) ?? - event.metadata.requestId ?? - null; + extractActivityRequestId(activity.payload) ?? event.metadata.requestId ?? null; if (requestId === null) { return; } @@ -1526,9 +1524,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: Option.isSome(existingRow) ? existingRow.value.threadId : event.payload.threadId, - turnId: Option.isSome(existingRow) - ? existingRow.value.turnId - : activity.turnId, + turnId: Option.isSome(existingRow) ? existingRow.value.turnId : activity.turnId, status: "resolved", decision: resolvedDecision, createdAt: Option.isSome(existingRow) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0d30bfb6..b5ef5a13 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -832,7 +832,7 @@ const make = Effect.gen(function* () { ? `\n${handoffBootstrapText}\n\n\n\n${boundaryMessageText}\n` : sidechatBootstrapText ? `\n${sidechatBootstrapText}\n\n\n${boundaryMessageText}` - : boundaryMessageText; + : boundaryMessageText; const normalizedInput = toNonEmptyProviderInput(providerInput); const normalizedAttachments = input.attachments ?? []; const activeSession = yield* providerService diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index dcbf333d..ad7ca474 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -111,9 +111,7 @@ describe("decider project scripts", () => { "project.deleted", "project.created", ]); - expect( - events.map((event) => (event.payload as { projectId: ProjectId }).projectId), - ).toEqual([ + expect(events.map((event) => (event.payload as { projectId: ProjectId }).projectId)).toEqual([ asProjectId("project-stale-a"), asProjectId("project-stale-b"), asProjectId("project-recreated"), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index f49656e2..4137baf2 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -101,6 +101,7 @@ import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { resolveCodexHome } from "@t3tools/shared/codexConfig"; import { deriveAssociatedWorktreeMetadata, workspaceRootsEqual, @@ -568,6 +569,8 @@ function stripRequestTag(body: T) { const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); +const CODEX_PETS_ROUTE_PREFIX = "/codex-pets"; +const SAFE_PET_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i; export type ServerCoreRuntimeServices = | OrchestrationEngineService @@ -600,6 +603,25 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro message: Schema.String, }) {} +interface CodexPetManifest { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly spritesheetPath: string; + readonly spritesheetUrl: string; +} + +function isSafePetId(petId: string): boolean { + return SAFE_PET_ID_PATTERN.test(petId) && !petId.includes(".."); +} + +function isPathWithinRoot(candidate: string, root: string, path: Path.Path): boolean { + return ( + candidate === root || + candidate.startsWith(root.endsWith(path.sep) ? root : `${root}${path.sep}`) + ); +} + // Summarize noisy websocket pushes so explicit debug logging stays useful // without dumping ANSI-heavy terminal redraw traffic into the server logs. function summarizePushForLog(push: WsPushEnvelopeBase): unknown { @@ -705,6 +727,103 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const logger = createLogger("ws"); const readiness = yield* makeServerReadiness; + // Codex pets live under CODEX_HOME and are served read-only for the renderer overlay. + const codexPetsRoot = path.resolve(resolveCodexHome(process.env), "pets"); + const readCodexPets = Effect.fnUntraced(function* () { + const petRootStat = yield* fileSystem + .stat(codexPetsRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!petRootStat || petRootStat.type !== "Directory") { + return [] as CodexPetManifest[]; + } + + const realPetsRoot = yield* Effect.try({ + try: () => realpathSync.native(codexPetsRoot), + catch: () => codexPetsRoot, + }); + const entries = yield* fileSystem + .readDirectory(codexPetsRoot, { recursive: false }) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))); + const pets: CodexPetManifest[] = []; + + for (const entry of entries) { + if (!isSafePetId(entry)) { + continue; + } + + const petDir = path.resolve(codexPetsRoot, entry); + const realPetDir = yield* Effect.try({ + try: () => realpathSync.native(petDir), + catch: () => petDir, + }); + if (!isPathWithinRoot(realPetDir, realPetsRoot, path)) { + continue; + } + + const manifestText = yield* fileSystem + .readFileString(path.join(petDir, "pet.json")) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!manifestText) { + continue; + } + + const parsed = yield* Effect.try({ + try: () => JSON.parse(manifestText) as Record, + catch: () => null, + }); + const id = typeof parsed?.id === "string" && isSafePetId(parsed.id) ? parsed.id : entry; + const displayName = + typeof parsed?.displayName === "string" && parsed.displayName.trim().length > 0 + ? parsed.displayName.trim() + : id; + const description = + typeof parsed?.description === "string" && parsed.description.trim().length > 0 + ? parsed.description.trim() + : `${displayName} Codex pet.`; + const spritesheetPath = + typeof parsed?.spritesheetPath === "string" && parsed.spritesheetPath === "spritesheet.webp" + ? parsed.spritesheetPath + : "spritesheet.webp"; + const spritesheetStat = yield* fileSystem + .stat(path.join(petDir, spritesheetPath)) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!spritesheetStat || spritesheetStat.type !== "File") { + continue; + } + + pets.push({ + id, + displayName, + description, + spritesheetPath, + spritesheetUrl: `${CODEX_PETS_ROUTE_PREFIX}/${encodeURIComponent(id)}/${spritesheetPath}`, + }); + } + + return pets.sort((left, right) => left.displayName.localeCompare(right.displayName)); + }); + + const resolveCodexPetAssetPath = Effect.fnUntraced(function* (petId: string, assetName: string) { + if (!isSafePetId(petId) || (assetName !== "pet.json" && assetName !== "spritesheet.webp")) { + return null; + } + + const realPetsRoot = yield* Effect.try({ + try: () => realpathSync.native(codexPetsRoot), + catch: () => codexPetsRoot, + }); + const assetPath = path.resolve(codexPetsRoot, petId, assetName); + const realAssetPath = yield* Effect.try({ + try: () => realpathSync.native(assetPath), + catch: () => assetPath, + }); + if (!isPathWithinRoot(realAssetPath, realPetsRoot, path)) { + return null; + } + + return assetPath; + }); + // Canonicalizes imported workspace roots once at the server boundary. const canonicalizeProjectWorkspaceRoot = Effect.fnUntraced(function* ( workspaceRoot: string, @@ -1010,6 +1129,68 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } + if (url.pathname === CODEX_PETS_ROUTE_PREFIX) { + const pets = yield* readCodexPets(); + respond( + 200, + { "Content-Type": "application/json; charset=utf-8" }, + JSON.stringify({ pets }), + ); + return; + } + + if (url.pathname.startsWith(`${CODEX_PETS_ROUTE_PREFIX}/`)) { + const rawRelativePath = url.pathname.slice(CODEX_PETS_ROUTE_PREFIX.length + 1); + const [rawPetId, rawAssetName, ...extraSegments] = rawRelativePath.split("/"); + const petId = decodeURIComponent(rawPetId ?? ""); + const assetName = decodeURIComponent(rawAssetName ?? ""); + if (extraSegments.length > 0) { + respond(400, { "Content-Type": "text/plain" }, "Invalid pet asset path"); + return; + } + + const filePath = yield* resolveCodexPetAssetPath(petId, assetName); + if (!filePath) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + + const contentType = + assetName === "pet.json" + ? "application/json; charset=utf-8" + : (Mime.getType(filePath) ?? "application/octet-stream"); + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": + assetName === "pet.json" ? "no-cache" : "public, max-age=31536000, immutable", + }); + const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => + Effect.sync(() => { + if (!res.destroyed) { + res.write(chunk); + } + }), + ).pipe(Effect.exit); + if (Exit.isFailure(streamExit)) { + if (!res.destroyed) { + res.destroy(); + } + return; + } + if (!res.writableEnded) { + res.end(); + } + return; + } + if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); diff --git a/apps/web/src/backgroundThreadActivity.test.ts b/apps/web/src/backgroundThreadActivity.test.ts new file mode 100644 index 00000000..802be6a1 --- /dev/null +++ b/apps/web/src/backgroundThreadActivity.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; + +import { + collectBackgroundThreadActivityItems, + getThreadIdsNeedingBackgroundDetail, + resolveBackgroundThreadActivityKind, +} from "./backgroundThreadActivity"; +import type { SidebarThreadSummary } from "./types"; + +function makeThread( + patch: Omit, "id" | "title"> & { id: string; title?: string }, +): SidebarThreadSummary { + return { + id: ThreadId.makeUnsafe(patch.id), + projectId: ProjectId.makeUnsafe("project-1"), + title: patch.title ?? patch.id, + modelSelection: { provider: "codex", model: "gpt-5.4" }, + interactionMode: "default", + branch: null, + worktreePath: null, + session: null, + createdAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + updatedAt: "2026-01-01T00:00:00.000Z", + latestTurn: null, + lastVisitedAt: undefined, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + hasLiveTailWork: false, + ...patch, + id: ThreadId.makeUnsafe(patch.id), + }; +} + +describe("backgroundThreadActivity", () => { + it("keeps running threads eligible for background detail subscriptions", () => { + const runningThread = makeThread({ + id: "thread-running", + session: { + provider: "codex", + status: "running", + activeTurnId: TurnId.makeUnsafe("turn-running"), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:01:00.000Z", + orchestrationStatus: "running", + }, + }); + + expect(resolveBackgroundThreadActivityKind(runningThread)).toBe("working"); + expect(getThreadIdsNeedingBackgroundDetail([runningThread])).toEqual([runningThread.id]); + }); + + it("shows only non-visible active threads in the background dock list", () => { + const visibleRunningThread = makeThread({ + id: "thread-visible", + hasLiveTailWork: true, + }); + const backgroundInputThread = makeThread({ + id: "thread-input", + title: "Needs answer", + hasPendingUserInput: true, + updatedAt: "2026-01-01T00:02:00.000Z", + }); + const idleThread = makeThread({ + id: "thread-idle", + updatedAt: "2026-01-01T00:03:00.000Z", + }); + + expect( + collectBackgroundThreadActivityItems({ + threads: [visibleRunningThread, backgroundInputThread, idleThread], + visibleThreadIds: new Set([visibleRunningThread.id]), + }), + ).toEqual([ + { + threadId: backgroundInputThread.id, + projectId: backgroundInputThread.projectId, + title: "Needs answer", + kind: "input-needed", + updatedAt: "2026-01-01T00:02:00.000Z", + }, + ]); + }); +}); diff --git a/apps/web/src/backgroundThreadActivity.ts b/apps/web/src/backgroundThreadActivity.ts new file mode 100644 index 00000000..80b5ca2b --- /dev/null +++ b/apps/web/src/backgroundThreadActivity.ts @@ -0,0 +1,127 @@ +// FILE: backgroundThreadActivity.ts +// Purpose: Derives cross-chat activity state for threads that keep working outside the visible chat. +// Layer: Global orchestration UI helpers +// Exports: background activity selectors used by subscriptions and the bottom activity dock + +import type { ThreadId } from "@t3tools/contracts"; +import { hasLiveLatestTurn } from "./session-logic"; +import type { SidebarThreadSummary } from "./types"; + +export type BackgroundThreadActivityKind = "input-needed" | "working" | "connecting"; + +export interface BackgroundThreadActivityItem { + readonly threadId: ThreadId; + readonly projectId: SidebarThreadSummary["projectId"]; + readonly title: string; + readonly kind: BackgroundThreadActivityKind; + readonly updatedAt: string; +} + +type BackgroundThreadSummaryInput = Pick< + SidebarThreadSummary, + | "archivedAt" + | "hasLiveTailWork" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "id" + | "latestTurn" + | "projectId" + | "session" + | "title" + | "updatedAt" +>; + +function isLiveOrchestrationStatus( + status: NonNullable["orchestrationStatus"] | null | undefined, +): boolean { + return status === "starting" || status === "running"; +} + +export function resolveBackgroundThreadActivityKind( + thread: BackgroundThreadSummaryInput, +): BackgroundThreadActivityKind | null { + if (thread.archivedAt != null) { + return null; + } + + if (thread.hasPendingApprovals || thread.hasPendingUserInput) { + return "input-needed"; + } + + if ( + thread.session?.status === "connecting" || + thread.session?.orchestrationStatus === "starting" + ) { + return "connecting"; + } + + if ( + thread.hasLiveTailWork || + thread.session?.status === "running" || + isLiveOrchestrationStatus(thread.session?.orchestrationStatus) || + thread.latestTurn?.state === "running" || + hasLiveLatestTurn(thread.latestTurn, thread.session) + ) { + return "working"; + } + + return null; +} + +export function getThreadIdsNeedingBackgroundDetail( + threads: readonly BackgroundThreadSummaryInput[], +): ThreadId[] { + return threads + .filter((thread) => resolveBackgroundThreadActivityKind(thread) !== null) + .map((thread) => thread.id); +} + +export function collectBackgroundThreadActivityItems(input: { + readonly threads: readonly BackgroundThreadSummaryInput[]; + readonly visibleThreadIds: ReadonlySet; + readonly limit?: number; +}): BackgroundThreadActivityItem[] { + const limit = input.limit ?? 4; + + return input.threads + .flatMap((thread): BackgroundThreadActivityItem[] => { + if (input.visibleThreadIds.has(thread.id)) { + return []; + } + + const kind = resolveBackgroundThreadActivityKind(thread); + if (!kind) { + return []; + } + + return [ + { + threadId: thread.id, + projectId: thread.projectId, + title: thread.title.trim() || "Untitled thread", + kind, + updatedAt: + thread.updatedAt ?? thread.latestTurn?.startedAt ?? thread.session?.updatedAt ?? "", + }, + ]; + }) + .toSorted((left, right) => { + const byPriority = activityPriority(right.kind) - activityPriority(left.kind); + if (byPriority !== 0) { + return byPriority; + } + return right.updatedAt.localeCompare(left.updatedAt); + }) + .slice(0, limit); +} + +function activityPriority(kind: BackgroundThreadActivityKind): number { + switch (kind) { + case "input-needed": + return 3; + case "working": + return 2; + case "connecting": + return 1; + } +} diff --git a/apps/web/src/backgroundThreadActivityPresentation.tsx b/apps/web/src/backgroundThreadActivityPresentation.tsx new file mode 100644 index 00000000..fde02744 --- /dev/null +++ b/apps/web/src/backgroundThreadActivityPresentation.tsx @@ -0,0 +1,56 @@ +// FILE: backgroundThreadActivityPresentation.tsx +// Purpose: Shares route visibility and presentation helpers for cross-chat activity surfaces. +// Layer: Global UI helpers +// Exports: split-aware visible thread hook plus activity labels/icons + +import { ThreadId } from "@t3tools/contracts"; +import { useParams, useSearch } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import type { BackgroundThreadActivityKind } from "./backgroundThreadActivity"; +import { parseDiffRouteSearch } from "./diffRouteSearch"; +import { LoaderCircleIcon, TriangleAlertIcon } from "./lib/icons"; +import { selectSplitView, useSplitViewStore } from "./splitViewStore"; +import { resolveVisibleToastThreadIds } from "./components/ui/toastRouteVisibility"; + +export function useVisibleThreadIdsFromRoute(): ReadonlySet { + const activeThreadId = useParams({ + strict: false, + select: (params) => + typeof params.threadId === "string" ? ThreadId.makeUnsafe(params.threadId) : null, + }); + const routeSearch = useSearch({ + strict: false, + select: (search) => parseDiffRouteSearch(search), + }); + const splitView = useSplitViewStore(selectSplitView(routeSearch.splitViewId ?? null)); + + return useMemo( + () => resolveVisibleToastThreadIds({ activeThreadId, splitView }), + [activeThreadId, splitView], + ); +} + +export function activityLabel(kind: BackgroundThreadActivityKind): string { + switch (kind) { + case "input-needed": + return "Input needed"; + case "connecting": + return "Connecting"; + case "working": + return "Working"; + } +} + +export function ActivityIcon({ + className = "size-3.5 shrink-0", + kind, +}: { + className?: string; + kind: BackgroundThreadActivityKind; +}) { + if (kind === "input-needed") { + return ; + } + return ; +} diff --git a/apps/web/src/components/BackgroundThreadActivityDock.tsx b/apps/web/src/components/BackgroundThreadActivityDock.tsx new file mode 100644 index 00000000..a9954746 --- /dev/null +++ b/apps/web/src/components/BackgroundThreadActivityDock.tsx @@ -0,0 +1,98 @@ +// FILE: BackgroundThreadActivityDock.tsx +// Purpose: Shows persistent bottom status for chats that keep working outside the visible route. +// Layer: Global UI overlay +// Depends on: sidebar thread summaries, route visibility, and thread navigation + +import type { ThreadId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useMemo } from "react"; + +import { collectBackgroundThreadActivityItems } from "../backgroundThreadActivity"; +import { + activityLabel, + ActivityIcon, + useVisibleThreadIdsFromRoute, +} from "../backgroundThreadActivityPresentation"; +import { MessageCircleIcon, TriangleAlertIcon } from "../lib/icons"; +import { cn } from "../lib/utils"; +import { useStore } from "../store"; +import { createSidebarThreadSummariesSelector } from "../storeSelectors"; + +const MAX_VISIBLE_BACKGROUND_THREADS = 3; + +export default function BackgroundThreadActivityDock() { + const navigate = useNavigate(); + const threads = useStore(useMemo(() => createSidebarThreadSummariesSelector(), [])); + const visibleThreadIds = useVisibleThreadIdsFromRoute(); + const activityItems = useMemo( + () => collectBackgroundThreadActivityItems({ threads, visibleThreadIds, limit: 12 }), + [threads, visibleThreadIds], + ); + + if (activityItems.length === 0) { + return null; + } + + if (typeof window !== "undefined" && window.desktopBridge?.petOverlay) { + return null; + } + + const visibleItems = activityItems.slice(0, MAX_VISIBLE_BACKGROUND_THREADS); + const hiddenCount = Math.max(0, activityItems.length - visibleItems.length); + const hasInputNeeded = activityItems.some((item) => item.kind === "input-needed"); + const title = + activityItems.length === 1 + ? `${activityLabel(activityItems[0]!.kind)} in another chat` + : `${activityItems.length} chats active`; + + const openThread = (threadId: ThreadId) => { + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => ({ ...previous, splitViewId: undefined }), + }); + }; + + return ( +
+
+
+ {hasInputNeeded ? ( + + ) : ( + + )} + {title} +
+ +
+ {visibleItems.map((item) => ( + + ))} + {hiddenCount > 0 && ( + +{hiddenCount} + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx index 99523acc..c0ad7ccf 100644 --- a/apps/web/src/components/BrowserPanel.tsx +++ b/apps/web/src/components/BrowserPanel.tsx @@ -507,14 +507,7 @@ export function BrowserPanel({ mode, threadId, onClosePanel }: BrowserPanelProps webview.removeEventListener("dom-ready", attachVisibleWebview); webview.removeEventListener("did-start-loading", attachVisibleWebview); }; - }, [ - activeTab, - api, - runBrowserAction, - threadId, - upsertThreadState, - workspaceReady, - ]); + }, [activeTab, api, runBrowserAction, threadId, upsertThreadState, workspaceReady]); useEffect(() => { return () => { @@ -1070,7 +1063,10 @@ export function BrowserPanel({ mode, threadId, onClosePanel }: BrowserPanelProps return (
-
+
{threadBrowserState?.tabs.map((tab) => { const isActive = tab.id === activeTab?.id; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a9e40817..be8945fe 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -32,8 +32,7 @@ import { hasLiveTurnTailWork, type WorkLogEntry } from "../session-logic"; import { localSubagentThreadId } from "./ChatView.selectors"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "dpcode:last-invoked-script-by-project"; -export const DISMISSED_PROVIDER_HEALTH_BANNERS_KEY = - "dpcode:dismissed-provider-health-banners"; +export const DISMISSED_PROVIDER_HEALTH_BANNERS_KEY = "dpcode:dismissed-provider-health-banners"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export const DismissedProviderHealthBannersSchema = Schema.Array(Schema.String); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1dfda295..be4f8dcb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -943,12 +943,11 @@ export default function ChatView({ {}, LastInvokedScriptByProjectSchema, ); - const [dismissedProviderHealthBannerKeys, setDismissedProviderHealthBannerKeys] = - useLocalStorage( - DISMISSED_PROVIDER_HEALTH_BANNERS_KEY, - [], - DismissedProviderHealthBannersSchema, - ); + const [dismissedProviderHealthBannerKeys, setDismissedProviderHealthBannerKeys] = useLocalStorage( + DISMISSED_PROVIDER_HEALTH_BANNERS_KEY, + [], + DismissedProviderHealthBannersSchema, + ); const [dismissedRateLimitBannerKey, setDismissedRateLimitBannerKey] = useState( null, ); @@ -6505,9 +6504,7 @@ export default function ChatView({ navigate({ to: "/$threadId", params: { threadId: nextThreadId }, - ...(options?.splitViewId - ? { search: () => ({ splitViewId: options.splitViewId }) } - : {}), + ...(options?.splitViewId ? { search: () => ({ splitViewId: options.splitViewId }) } : {}), }), handleClearConversation: async () => { if (!activeProject) { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 2dd4041e..998f8b43 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -3,10 +3,7 @@ import type { GitStackedAction, GitStatusResult, } from "@t3tools/contracts"; -import { - isTemporaryWorktreeBranch, - resolveUniqueDpcodeBranchName, -} from "@t3tools/shared/git"; +import { isTemporaryWorktreeBranch, resolveUniqueDpcodeBranchName } from "@t3tools/shared/git"; export type GitActionIconName = "commit" | "push" | "pr"; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 219f2981..3370cb4e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1145,6 +1145,7 @@ export default function Sidebar() { const activeSettingsSection = normalizeSettingsSection(settingsSectionSearch.section); const activeSplitView = useSplitViewStore(selectSplitView(routeSearch.splitViewId ?? null)); const splitViewsById = useSplitViewStore((store) => store.splitViewsById); + const createSplitViewFromDrop = useSplitViewStore((store) => store.createFromDrop); const setSplitFocusedPane = useSplitViewStore((store) => store.setFocusedPane); const removeThreadFromSplitViews = useSplitViewStore((store) => store.removeThreadFromSplitViews); const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ @@ -3046,6 +3047,14 @@ export default function Sidebar() { clearSelection, navigate, openChatThreadPage, + openSidechatSplit: ({ sourceThreadId, ownerProjectId, sidechatThreadId }) => + createSplitViewFromDrop({ + sourceThreadId, + ownerProjectId, + droppedThreadId: sidechatThreadId, + direction: "horizontal", + side: "second", + }), openTerminalThreadPage, prewarmThreadDetailForIntent, rememberLastThreadRouteNow, @@ -3352,27 +3361,24 @@ export default function Sidebar() { () => sortedProjects.filter((project) => isHomeChatContainerProject(project, homeDir)), [homeDir, sortedProjects], ); - const visibleChatThreadRows = useMemo( - () => { - if (!chatSectionExpanded) { - return []; - } - return buildProjectThreadTree({ - threads: sortThreadsForSidebar( - chatProjects.flatMap((project) => sortedSidebarThreadsByProjectId.get(project.id) ?? []), - appSettings.sidebarThreadSortOrder, - ), - expandedParentThreadIds: expandedSubagentParentIds, - }); - }, - [ - appSettings.sidebarThreadSortOrder, - chatSectionExpanded, - chatProjects, - expandedSubagentParentIds, - sortedSidebarThreadsByProjectId, - ], - ); + const visibleChatThreadRows = useMemo(() => { + if (!chatSectionExpanded) { + return []; + } + return buildProjectThreadTree({ + threads: sortThreadsForSidebar( + chatProjects.flatMap((project) => sortedSidebarThreadsByProjectId.get(project.id) ?? []), + appSettings.sidebarThreadSortOrder, + ), + expandedParentThreadIds: expandedSubagentParentIds, + }); + }, [ + appSettings.sidebarThreadSortOrder, + chatSectionExpanded, + chatProjects, + expandedSubagentParentIds, + sortedSidebarThreadsByProjectId, + ]); const visibleChatThreadIds = useMemo( () => visibleChatThreadRows.map((row) => row.thread.id), [visibleChatThreadRows], diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5884a9b9..afc1f76c 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -460,10 +460,10 @@ export const ChatHeader = memo(function ChatHeader({ > {showDiffTotals ? ( - + +{diffTotals?.insertions ?? 0} - + -{diffTotals?.deletions ?? 0} diff --git a/apps/web/src/components/pet/CodexPetLayer.tsx b/apps/web/src/components/pet/CodexPetLayer.tsx new file mode 100644 index 00000000..1212aafe --- /dev/null +++ b/apps/web/src/components/pet/CodexPetLayer.tsx @@ -0,0 +1,576 @@ +// FILE: CodexPetLayer.tsx +// Purpose: Renders local Codex Pet sprites as an isolated draggable Electron/web overlay. +// Layer: Global UI overlay +// Depends on: pet domain helpers, local /codex-pets HTTP assets, and desktop pet overlay IPC + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type MouseEvent, + type MutableRefObject, + type PointerEvent, +} from "react"; + +import { cn } from "~/lib/utils"; +import { activityLabel, ActivityIcon } from "~/backgroundThreadActivityPresentation"; +import { MessageCircleIcon } from "~/lib/icons"; +import { readNativeApi } from "~/nativeApi"; + +import { + PET_COLUMNS, + PET_RENDER_HEIGHT, + PET_RENDER_WIDTH, + PET_ROWS, + PET_STATE_ROWS, + shouldLoopPetAnimation, + type CodexPetAnimation, +} from "./petModel"; +import { + clampPosition, + defaultPosition, + readStoredPosition, + storePosition, + type PetPosition, +} from "./petPosition"; +import { useCodexPets } from "./useCodexPets"; +import { useGlobalPetAnimation } from "./useGlobalPetAnimation"; +import { usePetActivity } from "./usePetActivity"; +import { + dispatchPetVisibilityChanged, + PET_VISIBILITY_CHANGED_EVENT, + readPetEnabled, + storePetEnabled, +} from "./petVisibility"; + +const TAP_MOVEMENT_THRESHOLD = 8; +const PET_REACTION_DURATION_MS = 900; + +interface DragState { + readonly pointerId: number; + readonly startClientX: number; + readonly startClientY: number; + readonly startPosition: PetPosition; + readonly lastClientX: number; + readonly lastClientY: number; +} + +interface PetReaction { + readonly animation: Extract; + readonly until: number; +} + +type PetSpriteStyle = CSSProperties & { + "--codex-pet-duration"?: string; + "--codex-pet-steps"?: number; + "--codex-pet-iterations"?: number | "infinite"; + "--codex-pet-fill-mode"?: "none" | "forwards"; + "--codex-pet-sprite-x-end"?: string; +}; + +function useReducedMotion(): boolean { + const [reducedMotion, setReducedMotion] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(media.matches); + const onChange = () => setReducedMotion(media.matches); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + }, []); + + return reducedMotion; +} + +function useDocumentHidden(): boolean { + const [hidden, setHidden] = useState(() => + typeof document === "undefined" ? false : document.visibilityState !== "visible", + ); + + useEffect(() => { + if (typeof document === "undefined") return; + const updateHidden = () => setHidden(document.visibilityState !== "visible"); + updateHidden(); + document.addEventListener("visibilitychange", updateHidden); + return () => document.removeEventListener("visibilitychange", updateHidden); + }, []); + + return hidden; +} + +function useWindowFocused(): boolean { + const [focused, setFocused] = useState(() => + typeof document === "undefined" ? true : document.hasFocus(), + ); + + useEffect(() => { + if (typeof window === "undefined" || typeof document === "undefined") return; + const updateFocused = () => setFocused(document.hasFocus()); + updateFocused(); + window.addEventListener("focus", updateFocused); + window.addEventListener("blur", updateFocused); + document.addEventListener("visibilitychange", updateFocused); + return () => { + window.removeEventListener("focus", updateFocused); + window.removeEventListener("blur", updateFocused); + document.removeEventListener("visibilitychange", updateFocused); + }; + }, []); + + return focused; +} + +function usePetEnabled(): readonly [boolean, (enabled: boolean) => void] { + const [enabled, setEnabledState] = useState(readPetEnabled); + + useEffect(() => { + const onVisibilityChanged = (event: Event) => { + const detail = (event as CustomEvent<{ enabled?: unknown }>).detail; + if (typeof detail?.enabled === "boolean") { + setEnabledState(detail.enabled); + return; + } + setEnabledState(readPetEnabled()); + }; + window.addEventListener(PET_VISIBILITY_CHANGED_EVENT, onVisibilityChanged); + return () => window.removeEventListener(PET_VISIBILITY_CHANGED_EVENT, onVisibilityChanged); + }, []); + + const setEnabled = useCallback((nextEnabled: boolean) => { + storePetEnabled(nextEnabled); + setEnabledState(nextEnabled); + dispatchPetVisibilityChanged(nextEnabled); + }, []); + + return [enabled, setEnabled]; +} + +function usePetPosition(): { + readonly position: PetPosition; + readonly positionRef: MutableRefObject; + readonly setLivePosition: (position: PetPosition) => void; + readonly commitPosition: (position: PetPosition) => void; +} { + const [position, setPosition] = useState(() => + clampPosition(readStoredPosition() ?? defaultPosition()), + ); + const positionRef = useRef(position); + + useEffect(() => { + positionRef.current = position; + }, [position]); + + useEffect(() => { + const onResize = () => { + setPosition((current) => { + const next = clampPosition(current); + positionRef.current = next; + storePosition(next); + return next; + }); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + const setLivePosition = useCallback((nextPosition: PetPosition) => { + const clamped = clampPosition(nextPosition); + positionRef.current = clamped; + setPosition(clamped); + }, []); + + const commitPosition = useCallback((nextPosition: PetPosition) => { + const clamped = clampPosition(nextPosition); + positionRef.current = clamped; + setPosition(clamped); + storePosition(clamped); + }, []); + + return { position, positionRef, setLivePosition, commitPosition }; +} + +export default function CodexPetLayer() { + const pets = useCodexPets(); + const contextAnimation = useGlobalPetAnimation(); + const reducedMotion = useReducedMotion(); + const documentHidden = useDocumentHidden(); + const windowFocused = useWindowFocused(); + const [petEnabled, setPetEnabled] = usePetEnabled(); + const { position, positionRef, setLivePosition, commitPosition } = usePetPosition(); + const [dragging, setDragging] = useState(false); + const [dragDirection, setDragDirection] = useState<"left" | "right">("right"); + const [reaction, setReaction] = useState(null); + const dragStateRef = useRef(null); + const desktopOverlayActive = + typeof window !== "undefined" && + Boolean(window.desktopBridge?.petOverlay) && + (documentHidden || !windowFocused); + const desktopOverlayActiveRef = useRef(desktopOverlayActive); + const { activitySummary, openPrimaryActivity, primaryActivity } = + usePetActivity(desktopOverlayActive); + const closePet = useCallback(() => { + setPetEnabled(false); + void window.desktopBridge?.petOverlay?.close(); + }, [setPetEnabled]); + const showPetMenu = useCallback( + async (position?: { x: number; y: number }) => { + const clicked = await readNativeApi()?.contextMenu.show( + [{ id: "close-pet", label: "Close Pet", destructive: true }], + position, + ); + if (clicked === "close-pet") { + closePet(); + } + }, + [closePet], + ); + + const pet = pets.find((candidate) => candidate.id === "icarus") ?? pets[0] ?? null; + const animation = useMemo(() => { + const now = Date.now(); + if (dragging) { + return dragDirection === "left" ? "runningLeft" : "runningRight"; + } + if (reaction && reaction.until > now) { + return reaction.animation; + } + return contextAnimation; + }, [contextAnimation, dragDirection, dragging, reaction]); + const animationSpec = PET_STATE_ROWS[animation]; + + useEffect(() => { + desktopOverlayActiveRef.current = desktopOverlayActive; + }, [desktopOverlayActive]); + + const triggerTapReaction = useCallback(() => { + setReaction({ + animation: Math.random() > 0.45 ? "jumping" : "waving", + until: Date.now() + PET_REACTION_DURATION_MS, + }); + }, []); + + const applyDragMove = useCallback( + (clientX: number, clientY: number, pointerId: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return; + if (Math.abs(clientX - dragState.lastClientX) > 1) { + setDragDirection(clientX < dragState.lastClientX ? "left" : "right"); + } + const nextPosition = { + x: dragState.startPosition.x + clientX - dragState.startClientX, + y: dragState.startPosition.y + clientY - dragState.startClientY, + }; + dragStateRef.current = { + ...dragState, + lastClientX: clientX, + lastClientY: clientY, + }; + setLivePosition(nextPosition); + }, + [setLivePosition], + ); + + const finishDrag = useCallback( + (clientX: number, clientY: number, pointerId: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return; + const nextPosition = { + x: dragState.startPosition.x + clientX - dragState.startClientX, + y: dragState.startPosition.y + clientY - dragState.startClientY, + }; + const movedDistance = Math.hypot( + clientX - dragState.startClientX, + clientY - dragState.startClientY, + ); + dragStateRef.current = null; + setDragging(false); + commitPosition(nextPosition); + if (movedDistance <= TAP_MOVEMENT_THRESHOLD) { + triggerTapReaction(); + } else { + setReaction({ + animation: "jumping", + until: Date.now() + PET_REACTION_DURATION_MS, + }); + } + }, + [commitPosition, triggerTapReaction], + ); + + useEffect(() => { + if (!dragging) return; + + const onWindowPointerMove = (event: globalThis.PointerEvent) => { + applyDragMove(event.clientX, event.clientY, event.pointerId); + }; + const onWindowPointerUp = (event: globalThis.PointerEvent) => { + finishDrag(event.clientX, event.clientY, event.pointerId); + }; + + window.addEventListener("pointermove", onWindowPointerMove); + window.addEventListener("pointerup", onWindowPointerUp); + window.addEventListener("pointercancel", onWindowPointerUp); + return () => { + window.removeEventListener("pointermove", onWindowPointerMove); + window.removeEventListener("pointerup", onWindowPointerUp); + window.removeEventListener("pointercancel", onWindowPointerUp); + }; + }, [applyDragMove, dragging, finishDrag]); + + useEffect(() => { + if (!reaction) return; + const remainingMs = reaction.until - Date.now(); + if (remainingMs <= 0) { + setReaction(null); + return; + } + const timeout = window.setTimeout(() => setReaction(null), remainingMs); + return () => window.clearTimeout(timeout); + }, [reaction]); + + useEffect(() => { + const petOverlay = window.desktopBridge?.petOverlay; + if (!petOverlay) return; + + return petOverlay.onMoved((screenPosition) => { + const nextPosition = clampPosition({ + x: screenPosition.x - window.screenX, + y: screenPosition.y - window.screenY, + }); + positionRef.current = nextPosition; + storePosition(nextPosition); + // Background drags are already applied by the native overlay; avoid waking React per move. + if (!desktopOverlayActiveRef.current) { + setLivePosition(nextPosition); + } + }); + }, [positionRef, setLivePosition]); + + useEffect(() => { + if (desktopOverlayActive) return; + setLivePosition(positionRef.current); + }, [desktopOverlayActive, positionRef, setLivePosition]); + + useEffect(() => { + const petOverlay = window.desktopBridge?.petOverlay; + if (!petOverlay) return; + + return () => { + void petOverlay.hide(); + }; + }, []); + + useEffect(() => { + const petOverlay = window.desktopBridge?.petOverlay; + if (!petOverlay) return; + + if (!pet || !petEnabled) { + void petOverlay.close(); + return; + } + + void petOverlay.setState({ + visible: desktopOverlayActive, + spritesheetUrl: pet.spritesheetUrl, + displayName: pet.displayName, + description: pet.description, + animation, + activity: activitySummary, + row: animationSpec.row, + frames: animationSpec.frames, + durationMs: reducedMotion ? 0 : animationSpec.durationMs, + width: PET_RENDER_WIDTH, + height: PET_RENDER_HEIGHT, + columns: PET_COLUMNS, + rows: PET_ROWS, + x: Math.round(window.screenX + position.x), + y: Math.round(window.screenY + position.y), + }); + }, [ + animation, + animationSpec, + activitySummary, + desktopOverlayActive, + pet, + petEnabled, + position.x, + position.y, + reducedMotion, + ]); + + const spriteStyle = useMemo(() => { + const row = animationSpec.row; + const frames = animationSpec.frames; + const frameDurationMs = animationSpec.durationMs; + const loops = shouldLoopPetAnimation(animation); + const animatedSteps = loops ? frames : Math.max(1, frames - 1); + return { + width: PET_RENDER_WIDTH, + height: PET_RENDER_HEIGHT, + contain: "layout paint style", + overflow: "hidden", + isolation: "isolate", + transform: "translateZ(0)", + "--codex-pet-duration": `${frames * frameDurationMs}ms`, + "--codex-pet-steps": animatedSteps, + "--codex-pet-iterations": loops ? "infinite" : 1, + "--codex-pet-fill-mode": loops ? "none" : "forwards", + "--codex-pet-sprite-x-end": `${-(loops ? frames : frames - 1) * PET_RENDER_WIDTH}px`, + }; + }, [animation, animationSpec, pet]); + + const stripStyle = useMemo( + () => ({ + width: PET_RENDER_WIDTH * PET_COLUMNS, + height: PET_RENDER_HEIGHT, + backgroundImage: pet ? `url("${pet.spritesheetUrl}")` : undefined, + backgroundRepeat: "no-repeat", + backgroundSize: `${PET_RENDER_WIDTH * PET_COLUMNS}px ${PET_RENDER_HEIGHT * PET_ROWS}px`, + backgroundPosition: `0px ${-animationSpec.row * PET_RENDER_HEIGHT}px`, + imageRendering: "pixelated", + transform: "translate3d(0, 0, 0)", + }), + [animationSpec.row, pet], + ); + + useEffect(() => { + const onMenuAction = window.desktopBridge?.onMenuAction; + if (typeof onMenuAction !== "function") return; + return onMenuAction((action) => { + if (action === "show-pet") { + setPetEnabled(true); + } else if (action === "close-pet") { + closePet(); + } + }); + }, [closePet, setPetEnabled]); + + if (!pet || !petEnabled) { + return null; + } + + if (desktopOverlayActive) { + return null; + } + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragStateRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startPosition: position, + lastClientX: event.clientX, + lastClientY: event.clientY, + }; + setReaction(null); + setDragging(true); + }; + + const onPointerMove = (event: PointerEvent) => { + applyDragMove(event.clientX, event.clientY, event.pointerId); + }; + + const onPointerUp = (event: PointerEvent) => { + finishDrag(event.clientX, event.clientY, event.pointerId); + }; + + const isInputNeeded = primaryActivity?.kind === "input-needed"; + const onPetMenu = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void showPetMenu({ x: event.clientX, y: event.clientY }); + }; + + return ( +
+ + {primaryActivity ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/pet/petModel.test.ts b/apps/web/src/components/pet/petModel.test.ts new file mode 100644 index 00000000..36c3dd31 --- /dev/null +++ b/apps/web/src/components/pet/petModel.test.ts @@ -0,0 +1,88 @@ +// FILE: petModel.test.ts +// Purpose: Guards global pet animation resolution against persistent gesture loops. +// Layer: Web unit tests +// Exports: Vitest cases for petModel helpers + +import { describe, expect, it } from "vitest"; + +import { + PET_STATE_ROWS, + resolveGlobalPetAnimation, + resolvePetAnimation, + shouldLoopPetAnimation, +} from "./petModel"; + +describe("petModel", () => { + it("falls back to idle after a completed turn instead of looping a wave", () => { + expect( + resolvePetAnimation({ + sessionStatus: "ready", + latestTurnState: "completed", + hasPendingApprovals: false, + hasPendingUserInput: false, + error: null, + }), + ).toBe("idle"); + }); + + it("keeps the classic idle pose for active work", () => { + expect( + resolvePetAnimation({ + sessionStatus: "running", + orchestrationStatus: "running", + latestTurnState: "running", + hasPendingApprovals: false, + hasPendingUserInput: false, + error: null, + }), + ).toBe("idle"); + }); + + it("keeps background running work on the classic idle pose", () => { + expect( + resolveGlobalPetAnimation([ + { + sessionStatus: "ready", + latestTurnState: "completed", + hasPendingApprovals: false, + hasPendingUserInput: false, + error: null, + }, + { + sessionStatus: "running", + orchestrationStatus: "running", + latestTurnState: "running", + hasPendingApprovals: false, + hasPendingUserInput: false, + error: null, + }, + ]), + ).toBe("idle"); + }); + + it("keeps failed background threads from making the global pet cry", () => { + expect( + resolveGlobalPetAnimation([ + { + sessionStatus: "error", + latestTurnState: "error", + hasPendingApprovals: false, + hasPendingUserInput: false, + error: "failed turn", + }, + ]), + ).toBe("idle"); + }); + + it("uses a stable visible frame for the classic pose", () => { + expect(PET_STATE_ROWS.idle).toMatchObject({ row: 0, frames: 1 }); + }); + + it("keeps durable activity states animating but makes gesture poses one-shot", () => { + expect(shouldLoopPetAnimation("idle")).toBe(true); + expect(shouldLoopPetAnimation("review")).toBe(true); + expect(shouldLoopPetAnimation("running")).toBe(true); + expect(shouldLoopPetAnimation("waving")).toBe(false); + expect(shouldLoopPetAnimation("jumping")).toBe(false); + }); +}); diff --git a/apps/web/src/components/pet/petModel.ts b/apps/web/src/components/pet/petModel.ts new file mode 100644 index 00000000..7b9cf73a --- /dev/null +++ b/apps/web/src/components/pet/petModel.ts @@ -0,0 +1,94 @@ +// FILE: petModel.ts +// Purpose: Defines Codex pet sprite metadata and derives lightweight pet animation state. +// Layer: Global pet overlay domain helpers +// Exports: pet constants, manifest types, and animation resolution + +export const PET_COLUMNS = 8; +export const PET_ROWS = 9; +// Source tile is 192x208; render at exact half so pixel-art upscaling stays crisp and never sub-pixel clips. +export const PET_RENDER_WIDTH = 96; +export const PET_RENDER_HEIGHT = 104; + +export const PET_STATE_ROWS = { + idle: { row: 0, frames: 1, durationMs: 180 }, + runningRight: { row: 1, frames: 8, durationMs: 120 }, + runningLeft: { row: 2, frames: 8, durationMs: 120 }, + waving: { row: 3, frames: 4, durationMs: 150 }, + jumping: { row: 4, frames: 5, durationMs: 140 }, + failed: { row: 5, frames: 8, durationMs: 150 }, + waiting: { row: 6, frames: 6, durationMs: 170 }, + running: { row: 7, frames: 6, durationMs: 130 }, + review: { row: 8, frames: 6, durationMs: 155 }, +} as const; + +export type CodexPetAnimation = keyof typeof PET_STATE_ROWS; +export type CodexPetAnimationSpec = (typeof PET_STATE_ROWS)[CodexPetAnimation]; + +export interface CodexPetManifest { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly spritesheetUrl: string; +} + +export interface CodexPetThreadStateInput { + readonly archivedAt?: string | null; + readonly sessionStatus: string | null; + readonly orchestrationStatus?: string | null; + readonly latestTurnState: string | null; + readonly hasPendingApprovals: boolean; + readonly hasPendingUserInput: boolean; + readonly hasActionableProposedPlan?: boolean; + readonly hasLiveTailWork?: boolean; + readonly error: string | null; +} + +// Resolves durable thread state into a pet pose; transient celebration stays in the UI layer. +export function resolvePetAnimation(input: CodexPetThreadStateInput): CodexPetAnimation { + if (input.archivedAt != null) { + return "idle"; + } + if (input.error || input.latestTurnState === "error" || input.sessionStatus === "error") { + return "idle"; + } + if (input.hasPendingApprovals || input.hasPendingUserInput) { + return "waiting"; + } + if ( + input.hasLiveTailWork || + input.latestTurnState === "running" || + input.sessionStatus === "running" || + input.orchestrationStatus === "running" || + input.orchestrationStatus === "starting" + ) { + return "idle"; + } + if (input.sessionStatus === "starting" || input.sessionStatus === "connecting") { + return "idle"; + } + if (input.hasActionableProposedPlan) { + return "review"; + } + return "idle"; +} + +// Folds all chats into one pet pose so background work is visible outside the focused thread. +export function resolveGlobalPetAnimation( + threads: readonly CodexPetThreadStateInput[], +): CodexPetAnimation { + let hasReview = false; + + for (const thread of threads) { + const animation = resolvePetAnimation(thread); + if (animation === "waiting") return "waiting"; + hasReview ||= animation === "review"; + } + + if (hasReview) return "review"; + return "idle"; +} + +// Distinguishes durable state animations from one-shot reactions so gestures never loop forever. +export function shouldLoopPetAnimation(animation: CodexPetAnimation): boolean { + return animation !== "jumping" && animation !== "waving"; +} diff --git a/apps/web/src/components/pet/petPosition.ts b/apps/web/src/components/pet/petPosition.ts new file mode 100644 index 00000000..0c5e9685 --- /dev/null +++ b/apps/web/src/components/pet/petPosition.ts @@ -0,0 +1,53 @@ +// FILE: petPosition.ts +// Purpose: Keeps Codex pet position persistence and viewport clamping separate from rendering. +// Layer: Global pet overlay domain helpers +// Exports: pet position helpers used by renderer and desktop overlay sync + +import { PET_RENDER_HEIGHT, PET_RENDER_WIDTH } from "./petModel"; + +const PET_MARGIN = 12; +const PET_POSITION_STORAGE_KEY = "dpcode:codex-pet-position"; + +export interface PetPosition { + readonly x: number; + readonly y: number; +} + +export function readStoredPosition(): PetPosition | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(PET_POSITION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null; + return parsed; + } catch { + return null; + } +} + +export function storePosition(position: PetPosition): void { + try { + window.localStorage.setItem(PET_POSITION_STORAGE_KEY, JSON.stringify(position)); + } catch { + // Best-effort persistence only; dragging should still work in private contexts. + } +} + +export function defaultPosition(): PetPosition { + if (typeof window === "undefined") return { x: 24, y: 24 }; + return { + x: window.innerWidth - PET_RENDER_WIDTH - 28, + y: window.innerHeight - PET_RENDER_HEIGHT - 92, + }; +} + +export function clampPosition(position: PetPosition): PetPosition { + if (typeof window === "undefined") return position; + const maxX = Math.max(PET_MARGIN, window.innerWidth - PET_RENDER_WIDTH - PET_MARGIN); + const maxY = Math.max(PET_MARGIN, window.innerHeight - PET_RENDER_HEIGHT - PET_MARGIN); + return { + x: Math.min(maxX, Math.max(PET_MARGIN, position.x)), + y: Math.min(maxY, Math.max(PET_MARGIN, position.y)), + }; +} diff --git a/apps/web/src/components/pet/petVisibility.ts b/apps/web/src/components/pet/petVisibility.ts new file mode 100644 index 00000000..a0ad55b9 --- /dev/null +++ b/apps/web/src/components/pet/petVisibility.ts @@ -0,0 +1,30 @@ +// FILE: petVisibility.ts +// Purpose: Persists whether the Codex pet overlay is shown or user-dismissed. +// Layer: Global pet overlay domain helpers +// Exports: localStorage-backed visibility helpers and an event name for UI sync + +const PET_ENABLED_STORAGE_KEY = "dpcode:codex-pet-enabled"; + +export const PET_VISIBILITY_CHANGED_EVENT = "dpcode:codex-pet-visibility-changed"; + +export function readPetEnabled(): boolean { + if (typeof window === "undefined") return true; + try { + return window.localStorage.getItem(PET_ENABLED_STORAGE_KEY) !== "false"; + } catch { + return true; + } +} + +export function storePetEnabled(enabled: boolean): void { + try { + window.localStorage.setItem(PET_ENABLED_STORAGE_KEY, enabled ? "true" : "false"); + } catch { + // Best-effort preference persistence only; the in-memory state still updates. + } +} + +export function dispatchPetVisibilityChanged(enabled: boolean): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(PET_VISIBILITY_CHANGED_EVENT, { detail: { enabled } })); +} diff --git a/apps/web/src/components/pet/useCodexPets.ts b/apps/web/src/components/pet/useCodexPets.ts new file mode 100644 index 00000000..de3f0f42 --- /dev/null +++ b/apps/web/src/components/pet/useCodexPets.ts @@ -0,0 +1,34 @@ +// FILE: useCodexPets.ts +// Purpose: Loads local Codex pet manifests from the renderer asset route. +// Layer: Global pet overlay data hook +// Exports: useCodexPets + +import { useEffect, useState } from "react"; + +import type { CodexPetManifest } from "./petModel"; + +export function useCodexPets(): CodexPetManifest[] { + const [pets, setPets] = useState([]); + + useEffect(() => { + let cancelled = false; + void fetch("/codex-pets") + .then((response) => (response.ok ? response.json() : Promise.reject(response))) + .then((data: { pets?: CodexPetManifest[] }) => { + if (!cancelled) { + setPets(Array.isArray(data.pets) ? data.pets : []); + } + }) + .catch(() => { + if (!cancelled) { + setPets([]); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return pets; +} diff --git a/apps/web/src/components/pet/useGlobalPetAnimation.ts b/apps/web/src/components/pet/useGlobalPetAnimation.ts new file mode 100644 index 00000000..7f3d14cb --- /dev/null +++ b/apps/web/src/components/pet/useGlobalPetAnimation.ts @@ -0,0 +1,35 @@ +// FILE: useGlobalPetAnimation.ts +// Purpose: Reads all sidebar thread summaries needed to drive the global pet state. +// Layer: Global pet overlay route/store bridge +// Exports: useGlobalPetAnimation + +import { useMemo } from "react"; + +import { useStore } from "~/store"; +import { createSidebarThreadSummariesSelector } from "~/storeSelectors"; + +import { resolveGlobalPetAnimation, type CodexPetAnimation } from "./petModel"; + +export function useGlobalPetAnimation(): CodexPetAnimation { + const selectSidebarThreads = useMemo(() => createSidebarThreadSummariesSelector(), []); + + return useStore( + useMemo( + () => (state) => + resolveGlobalPetAnimation( + selectSidebarThreads(state).map((thread) => ({ + archivedAt: thread.archivedAt, + sessionStatus: thread.session?.status ?? null, + orchestrationStatus: thread.session?.orchestrationStatus ?? null, + latestTurnState: thread.latestTurn?.state ?? null, + hasPendingApprovals: thread.hasPendingApprovals, + hasPendingUserInput: thread.hasPendingUserInput, + hasActionableProposedPlan: thread.hasActionableProposedPlan, + hasLiveTailWork: thread.hasLiveTailWork, + error: thread.session?.lastError ?? null, + })), + ), + [selectSidebarThreads], + ), + ); +} diff --git a/apps/web/src/components/pet/usePetActivity.ts b/apps/web/src/components/pet/usePetActivity.ts new file mode 100644 index 00000000..846b61a2 --- /dev/null +++ b/apps/web/src/components/pet/usePetActivity.ts @@ -0,0 +1,82 @@ +// FILE: usePetActivity.ts +// Purpose: Derives the pet-attached activity pill from global thread summaries. +// Layer: Pet overlay UI hook +// Exports: usePetActivity for in-app and desktop pet overlay state + +import type { DesktopPetOverlayState, ThreadId } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; + +import { + collectBackgroundThreadActivityItems, + type BackgroundThreadActivityItem, +} from "~/backgroundThreadActivity"; +import { + activityLabel, + useVisibleThreadIdsFromRoute, +} from "~/backgroundThreadActivityPresentation"; +import { useStore } from "~/store"; +import { createSidebarThreadSummariesSelector } from "~/storeSelectors"; + +const MAX_PET_ACTIVITY_ITEMS = 12; +const NO_VISIBLE_THREAD_IDS = new Set(); + +export type PetActivitySummary = NonNullable; + +export function usePetActivity(desktopOverlayActive: boolean): { + readonly activityItems: readonly BackgroundThreadActivityItem[]; + readonly activitySummary: PetActivitySummary | null; + readonly openPrimaryActivity: () => void; + readonly primaryActivity: BackgroundThreadActivityItem | null; +} { + const navigate = useNavigate(); + const routeVisibleThreadIds = useVisibleThreadIdsFromRoute(); + const threads = useStore(useMemo(() => createSidebarThreadSummariesSelector(), [])); + const visibleThreadIds = desktopOverlayActive ? NO_VISIBLE_THREAD_IDS : routeVisibleThreadIds; + + const activityItems = useMemo( + () => + collectBackgroundThreadActivityItems({ + threads, + visibleThreadIds, + limit: MAX_PET_ACTIVITY_ITEMS, + }), + [threads, visibleThreadIds], + ); + const primaryActivity = activityItems[0] ?? null; + + const activitySummary = useMemo(() => { + if (!primaryActivity) { + return null; + } + + const hiddenActivityCount = Math.max(0, activityItems.length - 1); + return { + kind: primaryActivity.kind, + label: + activityItems.length === 1 + ? `${activityLabel(primaryActivity.kind)} in another chat` + : `${activityItems.length} chats active`, + title: + hiddenActivityCount > 0 + ? `${primaryActivity.title} +${hiddenActivityCount}` + : primaryActivity.title, + }; + }, [activityItems.length, primaryActivity]); + + const openPrimaryActivity = useCallback(() => { + if (!primaryActivity) return; + void navigate({ + to: "/$threadId", + params: { threadId: primaryActivity.threadId }, + search: (previous) => ({ ...previous, splitViewId: undefined }), + }); + }, [navigate, primaryActivity]); + + return { + activityItems, + activitySummary, + openPrimaryActivity, + primaryActivity, + }; +} diff --git a/apps/web/src/hooks/useThreadActivationController.test.ts b/apps/web/src/hooks/useThreadActivationController.test.ts index f245fed5..03e151a4 100644 --- a/apps/web/src/hooks/useThreadActivationController.test.ts +++ b/apps/web/src/hooks/useThreadActivationController.test.ts @@ -52,6 +52,7 @@ function makeControllerInput( navigate: ReturnType; clearSelection: ReturnType; openChatThreadPage: ReturnType; + openSidechatSplit: ReturnType; openTerminalThreadPage: ReturnType; prewarmThreadDetailForIntent: ReturnType; rememberLastThreadRouteNow: ReturnType; @@ -64,6 +65,7 @@ function makeControllerInput( clearSelection: vi.fn(), navigate: vi.fn(), openChatThreadPage: vi.fn(), + openSidechatSplit: vi.fn(() => "split-sidechat"), openTerminalThreadPage: vi.fn(), prewarmThreadDetailForIntent: vi.fn(), rememberLastThreadRouteNow: vi.fn(), @@ -271,4 +273,50 @@ describe("activateThreadFromSidebarIntent", () => { expect(input.openChatThreadPage).not.toHaveBeenCalled(); expect(getFirstNavigateArgs(input).params).toEqual({ threadId: THREAD_C }); }); + + it("opens sidechat rows beside their source thread when no persisted split exists", () => { + const input = makeControllerInput({ + routeThreadId: THREAD_A, + sidebarThreadSummaryById: { + [THREAD_A]: { id: THREAD_A, projectId: PROJECT_ID, sidechatSourceThreadId: null }, + [THREAD_B]: { id: THREAD_B, projectId: PROJECT_ID, sidechatSourceThreadId: THREAD_A }, + }, + }); + + activateThreadFromSidebarIntent(input, THREAD_B); + + expect(input.openSidechatSplit).toHaveBeenCalledWith({ + sourceThreadId: THREAD_A, + ownerProjectId: PROJECT_ID, + sidechatThreadId: THREAD_B, + }); + expect(input.openChatThreadPage).not.toHaveBeenCalled(); + expect(input.rememberLastThreadRouteNow).toHaveBeenCalledWith({ + threadId: THREAD_B, + splitViewId: "split-sidechat", + }); + expect(getFirstNavigateArgs(input).search({ keep: true })).toEqual({ + keep: true, + splitViewId: "split-sidechat", + }); + }); + + it("opens the active single sidechat as a split when clicked again", () => { + const input = makeControllerInput({ + routeThreadId: THREAD_B, + sidebarThreadSummaryById: { + [THREAD_A]: { id: THREAD_A, projectId: PROJECT_ID, sidechatSourceThreadId: null }, + [THREAD_B]: { id: THREAD_B, projectId: PROJECT_ID, sidechatSourceThreadId: THREAD_A }, + }, + }); + + activateThreadFromSidebarIntent(input, THREAD_B); + + expect(input.openSidechatSplit).toHaveBeenCalledWith({ + sourceThreadId: THREAD_A, + ownerProjectId: PROJECT_ID, + sidechatThreadId: THREAD_B, + }); + expect(input.navigate).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/src/hooks/useThreadActivationController.ts b/apps/web/src/hooks/useThreadActivationController.ts index 6574b075..fc1b6504 100644 --- a/apps/web/src/hooks/useThreadActivationController.ts +++ b/apps/web/src/hooks/useThreadActivationController.ts @@ -4,10 +4,11 @@ import { useCallback } from "react"; import type { useNavigate } from "@tanstack/react-router"; -import type { ThreadId } from "@t3tools/contracts"; +import type { ProjectId, ThreadId } from "@t3tools/contracts"; import type { LastThreadRoute } from "../chatRouteRestore"; import { type PaneId, type SplitView, type SplitViewId } from "../splitViewStore"; import { selectThreadTerminalState } from "../terminalStateStore"; +import type { SidebarThreadSummary } from "../types"; import { resolvePreferredSplitForCommand, resolveThreadCommandActivation, @@ -15,12 +16,21 @@ import { type Navigate = ReturnType; type ThreadTerminalStateById = Parameters[0]; +type SidebarThreadActivationSummary = Pick< + SidebarThreadSummary, + "id" | "projectId" | "sidechatSourceThreadId" +>; export type ThreadActivationControllerInput = { activeSplitView: SplitView | null; clearSelection: () => void; navigate: Navigate; openChatThreadPage: (threadId: ThreadId) => void; + openSidechatSplit: (input: { + sidechatThreadId: ThreadId; + sourceThreadId: ThreadId; + ownerProjectId: ProjectId; + }) => SplitViewId; openTerminalThreadPage: (threadId: ThreadId) => void; prewarmThreadDetailForIntent: (threadId: ThreadId) => void; rememberLastThreadRouteNow: (nextLastThreadRoute: LastThreadRoute) => void; @@ -30,7 +40,7 @@ export type ThreadActivationControllerInput = { setOptimisticActiveThreadId: (threadId: ThreadId) => void; setSelectionAnchor: (threadId: ThreadId) => void; setSplitFocusedPane: (splitViewId: SplitViewId, paneId: PaneId) => void; - sidebarThreadSummaryById: Readonly>>; + sidebarThreadSummaryById: Readonly>>; splitViewsById: Record; terminalStateByThreadId: ThreadTerminalStateById; }; @@ -45,6 +55,7 @@ export function activateThreadFromSidebarIntent( clearSelection, navigate, openChatThreadPage, + openSidechatSplit, openTerminalThreadPage, prewarmThreadDetailForIntent, rememberLastThreadRouteNow, @@ -65,14 +76,24 @@ export function activateThreadFromSidebarIntent( splitViewsById, threadId, }); + const targetThread = sidebarThreadSummaryById[threadId]; const activation = resolveThreadCommandActivation({ threadId, - threadExists: sidebarThreadSummaryById[threadId] !== undefined, + threadExists: targetThread !== undefined, activeSidebarThreadId: routeThreadId, preferredSplitViewId: preferredSplit?.splitViewId ?? null, splitPaneId: preferredSplit?.paneId ?? null, }); + const sidechatSplitActivation = resolveSidechatSplitActivation(input, { + threadId, + targetThread, + }); + if (sidechatSplitActivation && activation.kind !== "split") { + activateSidechatSplit(input, sidechatSplitActivation); + return; + } + if (activation.kind === "ignore") { return; } @@ -107,6 +128,66 @@ export function activateThreadFromSidebarIntent( }); } +function resolveSidechatSplitActivation( + input: ThreadActivationControllerInput, + options: { + threadId: ThreadId; + targetThread: SidebarThreadActivationSummary | undefined; + }, +): { threadId: ThreadId; sourceThreadId: ThreadId; ownerProjectId: ProjectId } | null { + if (!options.targetThread?.sidechatSourceThreadId) { + return null; + } + const sourceThread = input.sidebarThreadSummaryById[options.targetThread.sidechatSourceThreadId]; + if (!sourceThread) { + return null; + } + if (input.routeSplitViewId) { + return null; + } + return { + threadId: options.threadId, + sourceThreadId: options.targetThread.sidechatSourceThreadId, + ownerProjectId: sourceThread.projectId, + }; +} + +// Sidechat rows should reopen as source-left + sidechat-right even if no split was restored yet. +function activateSidechatSplit( + input: ThreadActivationControllerInput, + activation: { + threadId: ThreadId; + sourceThreadId: ThreadId; + ownerProjectId: ProjectId; + }, +): void { + input.prewarmThreadDetailForIntent(activation.sourceThreadId); + input.prewarmThreadDetailForIntent(activation.threadId); + input.setOptimisticActiveThreadId(activation.threadId); + if (input.selectedThreadCount > 0) { + input.clearSelection(); + } + input.setSelectionAnchor(activation.threadId); + + const splitViewId = input.openSidechatSplit({ + sourceThreadId: activation.sourceThreadId, + ownerProjectId: activation.ownerProjectId, + sidechatThreadId: activation.threadId, + }); + input.rememberLastThreadRouteNow({ + threadId: activation.threadId, + splitViewId, + }); + void input.navigate({ + to: "/$threadId", + params: { threadId: activation.threadId }, + search: (previous) => ({ + ...previous, + splitViewId, + }), + }); +} + // Opens the target as a single chat while preserving chat-vs-terminal entry point. function activateThreadSingle(input: ThreadActivationControllerInput, threadId: ThreadId): void { if (!input.sidebarThreadSummaryById[threadId]) return; @@ -146,6 +227,7 @@ export function useThreadActivationController(input: ThreadActivationControllerI clearSelection, navigate, openChatThreadPage, + openSidechatSplit, openTerminalThreadPage, prewarmThreadDetailForIntent, rememberLastThreadRouteNow, @@ -168,6 +250,7 @@ export function useThreadActivationController(input: ThreadActivationControllerI clearSelection, navigate, openChatThreadPage, + openSidechatSplit, openTerminalThreadPage, prewarmThreadDetailForIntent, rememberLastThreadRouteNow, @@ -189,6 +272,7 @@ export function useThreadActivationController(input: ThreadActivationControllerI clearSelection, navigate, openChatThreadPage, + openSidechatSplit, openTerminalThreadPage, prewarmThreadDetailForIntent, rememberLastThreadRouteNow, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 75ba7362..d8ebb9dc 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -82,6 +82,68 @@ transform: scale(0.7); will-change: opacity, transform; } + + /* Keep pet sprites animated by transform so frames don't repaint the chat renderer. */ + .codex-pet-sprite--animated { + animation: codex-pet-sprite-steps var(--codex-pet-duration) steps(var(--codex-pet-steps)) + var(--codex-pet-iterations, infinite); + animation-fill-mode: var(--codex-pet-fill-mode, none); + will-change: transform; + } + + /* Soft ellipse beneath the pet sprite to ground it visually above the activity dock. */ + .codex-pet-ground { + background: radial-gradient( + ellipse at center, + rgba(15, 23, 42, 0.28) 0%, + rgba(15, 23, 42, 0.16) 38%, + rgba(15, 23, 42, 0) 72% + ); + filter: blur(0.5px); + } + + .dark .codex-pet-ground { + background: radial-gradient( + ellipse at center, + rgba(0, 0, 0, 0.55) 0%, + rgba(0, 0, 0, 0.32) 38%, + rgba(0, 0, 0, 0) 72% + ); + } + + /* Glassy floating dock that visually echoes the pet without overpowering it. */ + .codex-pet-dock { + background: color-mix(in srgb, var(--popover) 78%, transparent); + box-shadow: + 0 1px 0 0 color-mix(in srgb, var(--color-white) 22%, transparent) inset, + 0 12px 30px -12px rgba(0, 0, 0, 0.45), + 0 4px 12px -4px rgba(0, 0, 0, 0.18); + } + + .dark .codex-pet-dock { + background: color-mix(in srgb, var(--popover) 86%, transparent); + box-shadow: + 0 1px 0 0 color-mix(in srgb, var(--color-white) 6%, transparent) inset, + 0 16px 32px -14px rgba(0, 0, 0, 0.7), + 0 6px 14px -6px rgba(0, 0, 0, 0.4); + } + + /* Tiny upward notch that anchors the dock to the pet so they read as one unit. */ + .codex-pet-dock-notch { + position: absolute; + top: -5px; + left: 50%; + width: 10px; + height: 10px; + border-top: 1px solid + var(--codex-pet-dock-notch-border, color-mix(in srgb, var(--border) 80%, transparent)); + border-left: 1px solid + var(--codex-pet-dock-notch-border, color-mix(in srgb, var(--border) 80%, transparent)); + background: inherit; + transform: translateX(-50%) rotate(45deg); + border-top-left-radius: 2px; + pointer-events: none; + } } @keyframes terminal-running-indicator-pulse { @@ -97,6 +159,16 @@ } } +@keyframes codex-pet-sprite-steps { + from { + transform: translate3d(0, 0, 0); + } + + to { + transform: translate3d(var(--codex-pet-sprite-x-end), 0, 0); + } +} + /* Smooth entry for chat pane swaps (empty landing <-> transcript+bottom composer). */ @keyframes chat-pane-enter { from { @@ -119,6 +191,11 @@ .chat-pane-enter { animation: none; } + + .codex-pet-sprite--animated { + animation: none; + will-change: auto; + } } /* Suppress all transitions during theme changes */ diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx index 371d85c8..13684a9a 100644 --- a/apps/web/src/lib/icons.tsx +++ b/apps/web/src/lib/icons.tsx @@ -40,7 +40,6 @@ import { IconFolderOpen, IconGitCompare, IconGitFork, - IconGitPullRequest, IconEdit, IconInfoCircle, IconLayoutSidebarLeftCollapse, @@ -156,7 +155,26 @@ export const GitForkIcon = adaptIcon(IconGitFork); export const GitHubIcon: LucideIcon = (props) => ( ); -export const GitPullRequestIcon = adaptIcon(IconGitPullRequest); +export const GitPullRequestIcon: LucideIcon = (props) => ( + +); export const GlobeIcon = adaptIcon(IconWorld); export const McpIcon: LucideIcon = (props) => ( diff --git a/apps/web/src/notifications/taskCompletion.tsx b/apps/web/src/notifications/taskCompletion.tsx index 860fc8c2..32d9c36e 100644 --- a/apps/web/src/notifications/taskCompletion.tsx +++ b/apps/web/src/notifications/taskCompletion.tsx @@ -208,6 +208,10 @@ export function TaskCompletionNotifications() { for (const completion of completions) { const copy = buildTaskCompletionCopy(completion); + if (settings.enableTaskCompletionToasts) { + showThreadToast(copy, completion.threadId, "success", navigate); + } + if (shouldAttemptSystemNotification) { void showSystemThreadNotification(copy, completion.threadId, navigate); } @@ -226,6 +230,10 @@ export function TaskCompletionNotifications() { for (const completion of terminalCompletions) { const copy = buildTerminalCompletionCopy(completion); + if (settings.enableTaskCompletionToasts) { + showThreadToast(copy, completion.threadId, "success", navigate); + } + if (shouldAttemptSystemNotification) { void showSystemThreadNotification(copy, completion.threadId, navigate); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c0b004ed..10bfc1a5 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -29,6 +29,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useFocusedChatContext } from "../focusedChatContext"; +import { getThreadIdsNeedingBackgroundDetail } from "../backgroundThreadActivity"; import { isTerminalFocused } from "../lib/terminalFocus"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; @@ -45,6 +46,8 @@ import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { TaskCompletionNotifications } from "../notifications/taskCompletion"; +import BackgroundThreadActivityDock from "../components/BackgroundThreadActivityDock"; +import CodexPetLayer from "../components/pet/CodexPetLayer"; import { useWorkspaceStore, workspaceThreadId } from "../workspaceStore"; import { subscribeRetainedThreadDetailIdChanges, @@ -60,6 +63,7 @@ import { hasLiveThreadsWithMissingProjects } from "../lib/desktopProjectRecovery import { parseDiffRouteSearch } from "../diffRouteSearch"; import { resolveSplitViewThreadIds, selectSplitView, useSplitViewStore } from "../splitViewStore"; import { providerDiscoveryQueryKeys } from "../lib/providerDiscoveryReactQuery"; +import { createSidebarThreadSummariesSelector } from "../storeSelectors"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -97,6 +101,8 @@ function RootRouteView() { + + @@ -358,17 +364,28 @@ function EventRouter() { return routeThreadId ? [routeThreadId] : []; }, [activeSplitView, routeThreadId]); const retainedThreadIds = useRetainedThreadDetailIds(); + const backgroundThreadSummaries = useStore( + useMemo(() => createSidebarThreadSummariesSelector(), []), + ); + const backgroundDetailThreadIds = useMemo( + () => getThreadIdsNeedingBackgroundDetail(backgroundThreadSummaries), + [backgroundThreadSummaries], + ); const subscribedThreadIds = useMemo(() => { const nextThreadIds = new Set(visibleThreadIds); for (const threadId of retainedThreadIds) { nextThreadIds.add(threadId); } + for (const threadId of backgroundDetailThreadIds) { + nextThreadIds.add(threadId); + } return [...nextThreadIds]; - }, [retainedThreadIds, visibleThreadIds]); + }, [backgroundDetailThreadIds, retainedThreadIds, visibleThreadIds]); const workspacePagesRef = useRef(workspacePages); const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); const routeVisibleThreadIdsRef = useRef(visibleThreadIds); + const backgroundDetailThreadIdsRef = useRef(backgroundDetailThreadIds); const visibleThreadIdsRef = useRef(subscribedThreadIds); const reconcileThreadSubscriptionsRef = useRef< ((threadIds: readonly ThreadId[]) => Promise) | null @@ -377,6 +394,7 @@ function EventRouter() { workspacePagesRef.current = workspacePages; pathnameRef.current = pathname; routeVisibleThreadIdsRef.current = visibleThreadIds; + backgroundDetailThreadIdsRef.current = backgroundDetailThreadIds; visibleThreadIdsRef.current = subscribedThreadIds; useEffect(() => { @@ -485,6 +503,9 @@ function EventRouter() { for (const threadId of nextRetainedThreadIds) { nextThreadIds.add(threadId); } + for (const threadId of backgroundDetailThreadIdsRef.current) { + nextThreadIds.add(threadId); + } void enqueueThreadSubscriptionReconcile([...nextThreadIds]); }, ); diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 92b33ab8..c6dc5f04 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -93,7 +93,10 @@ import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/component const DiffPanel = lazy(() => import("../components/DiffPanel")); const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; -const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; +// Default the inline diff to ~50% of the chat area (viewport minus the 16rem left +// sidebar) so it behaves like the split-chat 50/50 layout instead of overflowing past +// half the view. Users can still resize past these bounds via the sidebar rail. +const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem, calc(50vw - 8rem), 44rem)"; const BROWSER_INLINE_DEFAULT_WIDTH = "50%"; const SPLIT_PANE_PANEL_DEFAULT_WIDTH_PX = 22 * 16; const BROWSER_SPLIT_PANE_PANEL_DEFAULT_WIDTH_PX = 30 * 16; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index dbd1c2cd..e3c75e73 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,6 +6,7 @@ import { defineConfig } from "vite"; import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); +const serverPort = Number(process.env.T3CODE_PORT ?? 3773); const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); const buildSourcemap = @@ -48,6 +49,13 @@ export default defineConfig({ server: { port, strictPort: true, + proxy: { + // Pet manifests and sprites are owned by the backend; this keeps dev fetches same-origin. + "/codex-pets": { + target: `http://localhost:${serverPort}`, + changeOrigin: true, + }, + }, hmr: { // Explicit config so Vite's HMR WebSocket connects reliably // inside Electron's BrowserWindow. Vite 8 uses console.debug for diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e4945e1a..4a90c089 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -229,6 +229,47 @@ export interface DesktopNotificationInput { threadId?: ThreadId; } +export interface DesktopPetOverlayState { + visible: boolean; + spritesheetUrl: string; + displayName: string; + description: string; + animation: string; + activity?: { + kind: "input-needed" | "working" | "connecting"; + label: string; + title: string; + } | null; + row: number; + frames: number; + durationMs: number; + width: number; + height: number; + columns: number; + rows: number; + x: number; + y: number; +} + +export interface DesktopPetOverlayMoveDelta { + dx: number; + dy: number; +} + +export interface DesktopPetOverlayDragStartInput { + pointerWindowX: number; + pointerWindowY: number; +} + +export interface DesktopPetOverlayPointerInteractionInput { + interactive: boolean; +} + +export interface DesktopPetOverlayMovedEvent { + x: number; + y: number; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -258,6 +299,17 @@ export interface DesktopBridge { isSupported: () => Promise; show: (input: DesktopNotificationInput) => Promise; }; + petOverlay?: { + setState: (input: DesktopPetOverlayState) => Promise; + hide: () => Promise; + close: () => Promise; + moveBy: (input: DesktopPetOverlayMoveDelta) => Promise; + dragStart: (input: DesktopPetOverlayDragStartInput) => Promise; + dragMove: () => Promise; + dragEnd: () => Promise; + setPointerInteraction: (input: DesktopPetOverlayPointerInteractionInput) => Promise; + onMoved: (listener: (event: DesktopPetOverlayMovedEvent) => void) => () => void; + }; server?: { transcribeVoice: ( input: ServerVoiceTranscriptionInput, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 14fa7581..591e8641 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -65,8 +65,7 @@ export function resolveAutoFeatureBranchName( } export function buildDpcodeBranchName(preferredBranch?: string | null): string { - const normalizedExisting = - preferredBranch?.trim().replace(/^(codex|t3code|dpcode)\//i, "") ?? ""; + const normalizedExisting = preferredBranch?.trim().replace(/^(codex|t3code|dpcode)\//i, "") ?? ""; return `${WORKTREE_BRANCH_PREFIX}/${sanitizeBranchFragment( normalizedExisting || DPCODE_BRANCH_FALLBACK, )}`;