[codex] Add pet task center and desktop pet (#1833)

* feat: add pet task center and desktop pet

* Fix pet task center review regressions
This commit is contained in:
Eli 2026-05-19 15:38:39 +08:00 committed by GitHub
parent 6990291217
commit 4376d8a8ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1419 additions and 108 deletions

View file

@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// indirectly through legacy or future project-creation routes.
openPath: (projectId: string): Promise<string> =>
ipcRenderer.invoke('shell:open-path', projectId),
setDesktopPetVisible: (visible: boolean): void =>
ipcRenderer.send('desktop-pet:set-visible', Boolean(visible)),
});
contextBridge.exposeInMainWorld('__odDesktop', {

View file

@ -3,7 +3,7 @@ import { mkdir, writeFile, realpath, stat } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BrowserWindow, dialog, ipcMain, shell } from "electron";
import { BrowserWindow, dialog, ipcMain, screen, shell } from "electron";
import type { DesktopExportPdfInput, DesktopExportPdfResult } from "@open-design/sidecar-proto";
import { createElectronPdfTarget, exportPdfFromHtml, savePrintReadyDocumentAsPdf } from "./pdf-export.js";
@ -205,6 +205,9 @@ export function signDesktopImportToken(
const PENDING_POLL_MS = 120;
const RUNNING_POLL_MS = 2000;
const MAX_CONSOLE_ENTRIES = 200;
const DESKTOP_PET_WINDOW_WIDTH = 360;
const DESKTOP_PET_WINDOW_HEIGHT = 300;
const DESKTOP_PET_WINDOW_MARGIN = 24;
export type DesktopEvalInput = {
expression: string;
@ -624,6 +627,49 @@ function installWindowChromeCssHook(window: BrowserWindow): void {
});
}
function desktopPetUrl(baseUrl: string): string {
const url = new URL(baseUrl);
url.pathname = "/desktop-pet";
url.search = "";
url.hash = "";
return url.toString();
}
function createDesktopPetWindow(preloadPath: string): BrowserWindow {
const { workArea } = screen.getPrimaryDisplay();
const petWindow = new BrowserWindow({
width: DESKTOP_PET_WINDOW_WIDTH,
height: DESKTOP_PET_WINDOW_HEIGHT,
x: workArea.x + workArea.width - DESKTOP_PET_WINDOW_WIDTH - DESKTOP_PET_WINDOW_MARGIN,
y: workArea.y + workArea.height - DESKTOP_PET_WINDOW_HEIGHT - DESKTOP_PET_WINDOW_MARGIN,
show: false,
frame: false,
transparent: true,
backgroundColor: "#00000000",
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
hasShadow: false,
focusable: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: preloadPath,
sandbox: true,
},
});
petWindow.setAlwaysOnTop(true, "floating");
petWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
petWindow.webContents.setWindowOpenHandler(({ url }) => {
if (isHttpUrl(url)) void shell.openExternal(url);
return { action: "deny" };
});
petWindow.webContents.on("will-navigate", (event, url) => {
if (!url.includes("/desktop-pet")) event.preventDefault();
});
return petWindow;
}
function showWindowButtons(window: BrowserWindow): void {
if (process.platform !== "darwin" || window.isDestroyed()) return;
window.setWindowButtonVisibility(true);
@ -860,6 +906,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
});
const consoleEntries: DesktopConsoleEntry[] = [];
const petWindow = createDesktopPetWindow(preloadPath);
const window = new BrowserWindow({
height: 900,
// Below this size the project page's left/right split (chat
@ -883,6 +930,13 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
showWindowButtons(window);
attachDownloadSaveAsDialog(window);
ipcMain.removeAllListeners("desktop-pet:set-visible");
ipcMain.on("desktop-pet:set-visible", (event, visible: unknown) => {
if (petWindow.isDestroyed() || event.sender !== petWindow.webContents) return;
if (visible) petWindow.showInactive();
else petWindow.hide();
});
ipcMain.removeHandler('od:print-pdf');
ipcMain.handle('od:print-pdf', async (_event, html: unknown, nonce: unknown, options: unknown): Promise<void> => {
if (typeof html !== 'string') {
@ -909,6 +963,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
});
let currentUrl: string | null = null;
let currentPetUrl: string | null = null;
let pendingUrl: string | null = null;
let stopped = false;
let timer: NodeJS.Timeout | null = null;
@ -1006,6 +1061,11 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
currentUrl = url;
pendingUrl = null;
showWindowButtons(window);
const nextPetUrl = desktopPetUrl(url);
if (!petWindow.isDestroyed() && nextPetUrl !== currentPetUrl) {
await petWindow.loadURL(nextPetUrl);
currentPetUrl = nextPetUrl;
}
} else if (url == null) {
pendingUrl = null;
}
@ -1039,6 +1099,8 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
clearTimeout(timer);
timer = null;
}
ipcMain.removeAllListeners("desktop-pet:set-visible");
if (!petWindow.isDestroyed()) petWindow.close();
if (!window.isDestroyed()) window.close();
},
console() {

View file

@ -0,0 +1,12 @@
'use client';
import dynamic from 'next/dynamic';
const DesktopPetSurface = dynamic(
() => import('../../src/components/pet/DesktopPetSurface').then((m) => m.DesktopPetSurface),
{ ssr: false },
);
export function DesktopPetClient() {
return <DesktopPetSurface />;
}

View file

@ -0,0 +1,5 @@
import { DesktopPetClient } from './client';
export default function DesktopPetPage() {
return <DesktopPetClient />;
}

View file

@ -12,7 +12,8 @@ import { MarketplaceView } from './components/MarketplaceView';
import { PluginDetailView } from './components/PluginDetailView';
import type { CreateInput } from './components/NewProjectPanel';
import { MemoryToast } from './components/MemoryToast';
import { PetOverlay } from './components/pet/PetOverlay';
import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay';
import { buildPetTaskCenter } from './components/pet/taskCenter';
import { migrateCustomPetAtlas } from './components/pet/pets';
import { ProjectView } from './components/ProjectView';
import { WorkspaceTabsBar } from './components/WorkspaceTabsBar';
@ -37,6 +38,7 @@ import {
fetchSkills,
uploadProjectFiles,
} from './providers/registry';
import { RUNS_CHANGED_EVENT, listProjectRuns } from './providers/daemon';
import { navigate, useRoute } from './router';
import {
fetchDaemonConfig,
@ -183,6 +185,11 @@ export function App() {
const [designTemplates, setDesignTemplates] = useState<SkillSummary[]>([]);
const [designSystems, setDesignSystems] = useState<DesignSystemSummary[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [petTaskCenter, setPetTaskCenter] = useState<PetTaskCenter>({
running: [],
queued: [],
recent: [],
});
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
const [promptTemplates, setPromptTemplates] = useState<
PromptTemplateSummary[]
@ -927,6 +934,32 @@ export function App() {
navigate({ kind: 'project', projectId: id, fileName: null });
}, []);
useEffect(() => {
if (!config.pet?.enabled || !daemonLive) {
setPetTaskCenter({ running: [], queued: [], recent: [] });
return;
}
let cancelled = false;
const refresh = async () => {
const runs = await listProjectRuns();
if (cancelled) return;
setPetTaskCenter(buildPetTaskCenter(projects, runs));
};
const handleRunsChanged = () => {
void refresh();
};
void refresh();
window.addEventListener(RUNS_CHANGED_EVENT, handleRunsChanged);
const id = window.setInterval(refresh, 2000);
return () => {
cancelled = true;
window.removeEventListener(RUNS_CHANGED_EVENT, handleRunsChanged);
window.clearInterval(id);
};
}, [config.pet?.enabled, daemonLive, projects]);
const handleOpenLiveArtifact = useCallback((projectId: string, artifactId: string) => {
navigate({ kind: 'project', projectId, fileName: liveArtifactTabId(artifactId) });
}, []);
@ -1281,11 +1314,13 @@ export function App() {
/>
<div className="workspace-shell__body">{appMain}</div>
</div>
<PetOverlay
pet={config.pet?.enabled ? config.pet : undefined}
onTuck={handleTuckPet}
onOpenSettings={openPetSettings}
/>
{clientType === 'desktop' ? null : (
<PetOverlay
pet={config.pet?.enabled ? config.pet : undefined}
taskCenter={petTaskCenter}
onOpenProject={handleOpenProject}
/>
)}
{settingsOpen ? (
<SettingsDialog
initial={config}

View file

@ -323,6 +323,7 @@ export function EntryShell({
const metadata: ProjectMetadata = {
...(payload.projectMetadata ?? {}),
kind: payload.projectKind ?? payload.projectMetadata?.kind ?? 'prototype',
nameSource: 'prompt',
...(payload.contextPlugins && payload.contextPlugins.length > 0
? { contextPlugins: payload.contextPlugins }
: {}),

View file

@ -483,6 +483,7 @@ export function NewProjectPanel({
? videoPromptTemplate
: null
: null;
const trimmedName = name.trim();
const metadata = buildMetadata({
tab,
mediaSurface,
@ -524,10 +525,13 @@ export function NewProjectPanel({
{ requestId },
);
onCreate({
name: name.trim() || autoName(tab, mediaSurface, t),
name: trimmedName || autoName(tab, mediaSurface, t),
skillId: skillIdForTab,
designSystemId: primaryDs,
metadata,
metadata: {
...metadata,
nameSource: trimmedName ? 'user' : 'generated',
},
requestId,
});
}

View file

@ -45,6 +45,10 @@ import { projectKindToTracking } from '@open-design/contracts/analytics';
import { navigate } from '../router';
import { agentDisplayName, agentModelDisplayName } from '../utils/agentLabels';
import { isMacPlatform } from '../utils/platform';
import {
canAutoRenameProjectFromPrompt,
summarizeProjectNameFromPrompt,
} from '../utils/projectName';
import {
apiProtocolAgentId,
apiProtocolModelLabel,
@ -1798,6 +1802,27 @@ export function ProjectView({
);
void patchConversation(project.id, runConversationId, { title });
}
const projectName = summarizeProjectNameFromPrompt(prompt);
if (
projectName &&
projectName !== project.name &&
canAutoRenameProjectFromPrompt(project)
) {
const metadata = project.metadata
? { ...project.metadata, nameSource: 'prompt' as const }
: undefined;
const updated: Project = {
...project,
name: projectName,
...(metadata ? { metadata } : {}),
updatedAt: Date.now(),
};
onProjectChange(updated);
void patchProject(project.id, {
name: projectName,
...(metadata ? { metadata } : {}),
});
}
}
// Snapshot the file list at turn-start so we can diff after the
@ -2188,6 +2213,7 @@ export function ProjectView({
composedSystemPrompt,
onTouchProject,
project.id,
project.name,
projectFiles,
refreshProjectFiles,
refreshLiveArtifacts,
@ -2201,6 +2227,7 @@ export function ProjectView({
clearStreamingMarker,
clearActiveRunRefs,
onProjectsRefresh,
onProjectChange,
],
);
@ -2657,9 +2684,20 @@ export function ProjectView({
(newName: string) => {
const trimmed = newName.trim();
if (!trimmed || trimmed === project.name) return;
const updated: Project = { ...project, name: trimmed, updatedAt: Date.now() };
const metadata = project.metadata
? { ...project.metadata, nameSource: 'user' as const }
: undefined;
const updated: Project = {
...project,
name: trimmed,
...(metadata ? { metadata } : {}),
updatedAt: Date.now(),
};
onProjectChange(updated);
void patchProject(project.id, { name: trimmed });
void patchProject(project.id, {
name: trimmed,
...(metadata ? { metadata } : {}),
});
},
[project, onProjectChange],
);

View file

@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import { RUNS_CHANGED_EVENT, listProjectRuns } from '../../providers/daemon';
import { loadConfig } from '../../state/config';
import { listProjects } from '../../state/projects';
import type { AppConfig } from '../../types';
import { PetOverlay, type PetTaskCenter } from './PetOverlay';
import { buildPetTaskCenter } from './taskCenter';
const CONFIG_POLL_MS = 1500;
const TASK_POLL_MS = 2000;
export function DesktopPetSurface() {
const [config, setConfig] = useState<AppConfig>(() => loadConfig());
const [taskCenter, setTaskCenter] = useState<PetTaskCenter>({
running: [],
queued: [],
recent: [],
});
const pet = config.pet?.enabled ? config.pet : undefined;
useEffect(() => {
document.body.classList.add('desktop-pet-shell');
return () => document.body.classList.remove('desktop-pet-shell');
}, []);
useEffect(() => {
const refresh = () => setConfig(loadConfig());
window.addEventListener('storage', refresh);
const id = window.setInterval(refresh, CONFIG_POLL_MS);
return () => {
window.removeEventListener('storage', refresh);
window.clearInterval(id);
};
}, []);
useEffect(() => {
window.electronAPI?.setDesktopPetVisible?.(Boolean(pet));
}, [pet]);
useEffect(() => {
if (!pet) {
setTaskCenter({ running: [], queued: [], recent: [] });
return;
}
let cancelled = false;
const refresh = async () => {
const [projects, runs] = await Promise.all([
listProjects(),
listProjectRuns(),
]);
if (cancelled) return;
setTaskCenter(buildPetTaskCenter(projects, runs));
};
const handleRunsChanged = () => {
void refresh();
};
void refresh();
window.addEventListener(RUNS_CHANGED_EVENT, handleRunsChanged);
const id = window.setInterval(refresh, TASK_POLL_MS);
return () => {
cancelled = true;
window.removeEventListener(RUNS_CHANGED_EVENT, handleRunsChanged);
window.clearInterval(id);
};
}, [pet]);
return (
<PetOverlay
pet={pet}
taskCenter={taskCenter}
persistentBubble
/>
);
}

View file

@ -1,9 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useT } from '../../i18n';
import { Icon } from '../Icon';
import type { PetConfig } from '../../types';
import {
ambientLines,
pickAmbientRow,
preferredRowId,
resolveActivePet,
@ -13,12 +11,39 @@ import { PetSpriteFace } from './PetSpriteFace';
interface Props {
pet: PetConfig | undefined;
onTuck: () => void;
onOpenSettings: () => void;
taskCenter?: PetTaskCenter;
onOpenProject?: (projectId: string) => void;
persistentBubble?: boolean;
}
const STORAGE_KEY = 'open-design:pet-position';
export interface PetTaskSummary {
projectId: string;
projectName: string;
status: 'queued' | 'running';
count: number;
}
export interface PetRecentTaskSummary {
projectId: string;
projectName: string;
status: 'succeeded' | 'failed' | 'canceled';
updatedAt: number;
}
export interface PetTaskCenter {
running: PetTaskSummary[];
queued: PetTaskSummary[];
recent: PetRecentTaskSummary[];
}
const EMPTY_TASK_CENTER: PetTaskCenter = {
running: [],
queued: [],
recent: [],
};
interface Position {
// Distances from the right/bottom of the viewport so the overlay
// sticks to the corner across resizes. Saved in localStorage.
@ -57,6 +82,12 @@ const DRAG_GESTURE_MIN_PX = 14;
// jumping/waving so diagonal drags don't strobe between rows.
const DRAG_AXIS_BIAS = 1.18;
const IDLE_QUOTE_COUNT = 6;
function recentTaskKey(task: PetRecentTaskSummary): string {
return `${task.projectId}:${task.updatedAt}`;
}
function loadPosition(): Position {
if (typeof window === 'undefined') return DEFAULT_POSITION;
try {
@ -83,10 +114,17 @@ function savePosition(p: Position) {
// Compact floating sprite + speech bubble. Rendered at the document
// root via App.tsx so it stays put when the user navigates between
// the entry and project views.
export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
export function PetOverlay({
pet,
taskCenter = EMPTY_TASK_CENTER,
onOpenProject,
persistentBubble = false,
}: Props) {
const t = useT();
const active = useMemo(() => resolveActivePet(pet), [pet]);
const [bubbleOpen, setBubbleOpen] = useState(false);
const [bubbleOpen, setBubbleOpen] = useState(persistentBubble);
const [acknowledgedRecentKeys, setAcknowledgedRecentKeys] = useState<Set<string>>(() => new Set());
const [viewingRecentKeys, setViewingRecentKeys] = useState<Set<string>>(() => new Set());
const [ambientIdx, setAmbientIdx] = useState(0);
const [position, setPosition] = useState<Position>(() => loadPosition());
// Interaction state drives which atlas row plays. Only meaningful
@ -117,19 +155,94 @@ export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
useEffect(() => {
if (!active) return;
setBubbleOpen(true);
if (persistentBubble) return;
const id = window.setTimeout(() => setBubbleOpen(false), 4000);
return () => window.clearTimeout(id);
}, [active?.id]);
}, [active?.id, persistentBubble]);
useEffect(() => {
savePosition(position);
}, [position]);
const lines = useMemo(
() => (active ? [active.greeting, ...ambientLines(active.name)] : []),
[active],
const idleQuotes = useMemo(
() => [
{
text: t('pet.idleQuote.leonardo.text'),
author: t('pet.idleQuote.leonardo.author'),
},
{
text: t('pet.idleQuote.michelangelo.text'),
author: t('pet.idleQuote.michelangelo.author'),
},
{
text: t('pet.idleQuote.bernini.text'),
author: t('pet.idleQuote.bernini.author'),
},
{
text: t('pet.idleQuote.raphael.text'),
author: t('pet.idleQuote.raphael.author'),
},
{
text: t('pet.idleQuote.caravaggio.text'),
author: t('pet.idleQuote.caravaggio.author'),
},
{
text: t('pet.idleQuote.rodin.text'),
author: t('pet.idleQuote.rodin.author'),
},
],
[t],
);
const visibleLine = lines.length > 0 ? lines[ambientIdx % lines.length] : '';
const visibleQuote = idleQuotes[ambientIdx % IDLE_QUOTE_COUNT] ?? idleQuotes[0];
const activeTasks = [...taskCenter.running, ...taskCenter.queued];
const unacknowledgedRecentTasks = taskCenter.recent.filter(
(task) => !acknowledgedRecentKeys.has(recentTaskKey(task)),
);
const visibleRecentTasks = bubbleOpen
? persistentBubble && viewingRecentKeys.size === 0
? unacknowledgedRecentTasks
: taskCenter.recent.filter((task) => viewingRecentKeys.has(recentTaskKey(task)))
: unacknowledgedRecentTasks;
const activeTaskCount = activeTasks.reduce((sum, task) => sum + task.count, 0);
const recentTaskCount = visibleRecentTasks.length;
const taskTotal = activeTaskCount + recentTaskCount;
const badgeTotal = activeTaskCount + unacknowledgedRecentTasks.length;
const taskSummaryLine =
activeTaskCount > 0
? t(
activeTaskCount === 1
? 'pet.taskSummarySingle'
: 'pet.taskSummaryMultiple',
{
count: activeTaskCount,
projects: new Set(activeTasks.map((task) => task.projectId)).size,
},
)
: recentTaskCount > 0
? t(
recentTaskCount === 1
? 'pet.taskSummaryRecentSingle'
: 'pet.taskSummaryRecentMultiple',
{ count: recentTaskCount },
)
: '';
const visibleLine = taskSummaryLine || visibleQuote?.text || active?.greeting || '';
const taskRowId =
taskTotal > 0 && interaction === 'idle' ? 'waiting' : undefined;
const acknowledgeRecentTasks = useCallback((tasks: PetRecentTaskSummary[]) => {
if (tasks.length === 0) {
setViewingRecentKeys(new Set());
return;
}
const keys = tasks.map(recentTaskKey);
setViewingRecentKeys(new Set(keys));
setAcknowledgedRecentKeys((prev) => {
const next = new Set(prev);
for (const key of keys) next.add(key);
return next;
});
}, []);
// (Re)arms the long-idle waiting timer. Called every time the user
// interacts so an active session never falls into "waiting" mid-drag.
@ -273,11 +386,21 @@ export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
}
// A tap (no drag) toggles the speech bubble and rotates the line.
if (drag && !drag.moved) {
setBubbleOpen((open) => {
const next = !open;
if (next) setAmbientIdx((i) => (i + 1) % Math.max(1, lines.length));
return next;
});
if (unacknowledgedRecentTasks.length > 0) {
setBubbleOpen(true);
setAmbientIdx((i) => i + 1);
acknowledgeRecentTasks(unacknowledgedRecentTasks);
} else {
setBubbleOpen((open) => {
const next = !open;
if (next) {
setAmbientIdx((i) => i + 1);
} else {
setViewingRecentKeys(new Set());
}
return next;
});
}
}
// After the drag ends, fall back to the resting animation so the
// pet stops "running" the moment the user lets go. Hovered state
@ -317,27 +440,36 @@ export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
{bubbleOpen ? (
<div className="pet-bubble" role="status">
<div className="pet-bubble-name">{active.name}</div>
<div className="pet-bubble-line">{visibleLine}</div>
<div className="pet-bubble-actions">
<button
type="button"
className="pet-bubble-btn"
onClick={onOpenSettings}
title={t('pet.settingsTitle')}
>
<Icon name="settings" size={12} />
<span>{t('pet.changePet')}</span>
</button>
<button
type="button"
className="pet-bubble-btn"
onClick={onTuck}
title={t('pet.tuckTitle')}
>
<Icon name="close" size={12} />
<span>{t('pet.tuck')}</span>
</button>
</div>
{taskTotal > 0 ? (
<div className="pet-bubble-line">{visibleLine}</div>
) : (
<figure className="pet-idle-quote">
<blockquote>{visibleLine}</blockquote>
{visibleQuote?.author ? <figcaption>{visibleQuote.author}</figcaption> : null}
</figure>
)}
{taskTotal > 0 ? (
<div className="pet-task-list" aria-label={t('pet.taskListAria')}>
<TaskGroup
title={t('pet.taskGroup.running')}
tasks={taskCenter.running}
onOpenProject={onOpenProject}
openTitle={(project) => t('pet.taskOpenProject', { project })}
/>
<TaskGroup
title={t('pet.taskGroup.queued')}
tasks={taskCenter.queued}
onOpenProject={onOpenProject}
openTitle={(project) => t('pet.taskOpenProject', { project })}
/>
<RecentTaskGroup
title={t('pet.taskGroup.recent')}
tasks={visibleRecentTasks}
onOpenProject={onOpenProject}
openTitle={(project) => t('pet.taskOpenProject', { project })}
/>
</div>
) : null}
</div>
) : null}
<div
@ -364,10 +496,116 @@ export function PetOverlay({ pet, onTuck, onOpenSettings }: Props) {
<PetSpriteFace
active={active}
className="pet-sprite-glyph"
rowId={ambientRowId ?? preferredRowId(interaction)}
rowId={ambientRowId ?? taskRowId ?? preferredRowId(interaction)}
/>
{badgeTotal > 0 ? (
<span className="pet-sprite-status" aria-label={visibleLine}>
{badgeTotal}
</span>
) : null}
<span className="pet-sprite-shadow" aria-hidden />
</div>
</div>
);
}
function TaskItem({
children,
clickable,
onClick,
title,
}: {
children: ReactNode;
clickable: boolean;
onClick?: () => void;
title: string;
}) {
if (clickable) {
return (
<button
type="button"
className="pet-task-item"
onClick={onClick}
title={title}
>
{children}
</button>
);
}
return (
<div className="pet-task-item pet-task-item--static" title={title}>
{children}
</div>
);
}
function TaskGroup({
title,
tasks,
onOpenProject,
openTitle,
}: {
title: string;
tasks: PetTaskSummary[];
onOpenProject?: (projectId: string) => void;
openTitle: (projectName: string) => string;
}) {
if (tasks.length === 0) return null;
return (
<section className="pet-task-group">
<div className="pet-task-group-title">{title}</div>
{tasks.slice(0, 3).map((task) => (
<TaskItem
key={task.projectId}
clickable={Boolean(onOpenProject)}
onClick={onOpenProject ? () => onOpenProject(task.projectId) : undefined}
title={openTitle(task.projectName)}
>
<span
className="pet-task-dot"
data-pet-task-status={task.status}
aria-hidden
/>
<span className="pet-task-name">{task.projectName}</span>
{task.count > 1 ? (
<span className="pet-task-count">{task.count}</span>
) : null}
</TaskItem>
))}
</section>
);
}
function RecentTaskGroup({
title,
tasks,
onOpenProject,
openTitle,
}: {
title: string;
tasks: PetRecentTaskSummary[];
onOpenProject?: (projectId: string) => void;
openTitle: (projectName: string) => string;
}) {
if (tasks.length === 0) return null;
return (
<section className="pet-task-group">
<div className="pet-task-group-title">{title}</div>
{tasks.slice(0, 3).map((task) => (
<TaskItem
key={`${task.projectId}:${task.updatedAt}`}
clickable={Boolean(onOpenProject)}
onClick={onOpenProject ? () => onOpenProject(task.projectId) : undefined}
title={openTitle(task.projectName)}
>
<span
className="pet-task-dot"
data-pet-task-status={task.status}
aria-hidden
/>
<span className="pet-task-name">{task.projectName}</span>
</TaskItem>
))}
</section>
);
}

View file

@ -16,6 +16,7 @@ import {
FPS_MIN,
FRAMES_MAX,
FRAMES_MIN,
prepareCodexPetCustom,
resolveActivePet,
} from './pets';
import { PetSpriteFace } from './PetSpriteFace';
@ -81,6 +82,10 @@ export function PetSettings({ cfg, setCfg }: Props) {
const [codexPetsLoading, setCodexPetsLoading] = useState(false);
const [codexPetsRoot, setCodexPetsRoot] = useState<string>('');
const [codexAdopting, setCodexAdopting] = useState<string | null>(null);
const [petActionStatus, setPetActionStatus] = useState<{
kind: 'shown' | 'hidden' | 'adopted';
name?: string;
} | null>(null);
// Community catalog sync — calls the daemon-side port of the
// `sync-community-pets` script which fetches the latest pets from
// Codex Pet Share + j20 Hatchery into `~/.codex/pets/`. We surface
@ -161,6 +166,12 @@ export function PetSettings({ cfg, setCfg }: Props) {
void refreshCodexPets();
}, [refreshCodexPets]);
useEffect(() => {
if (!petActionStatus) return;
const timer = window.setTimeout(() => setPetActionStatus(null), 2400);
return () => window.clearTimeout(timer);
}, [petActionStatus]);
const update = (patch: Partial<PetConfig>) => {
setCfg((curr) => {
const prev = curr.pet ?? { ...DEFAULT_PET, custom: defaultCustomPet() };
@ -342,32 +353,21 @@ export function PetSettings({ cfg, setCfg }: Props) {
// (idle ↔ waving ↔ running-*) just like the upstream
// `codex-pets-react` `PetWidget`. Defaults `name`/`greeting` from the
// manifest so the speech bubble feels personalized.
async function adoptCodexPet(pet: CodexPetSummary) {
async function adoptCodexPet(pet: CodexPetSummary): Promise<boolean> {
setCodexAdopting(pet.id);
setUploadError(null);
try {
const resp = await fetch(codexPetSpritesheetUrl(pet));
if (!resp.ok) throw new Error('Could not download that pet.');
const blob = await resp.blob();
const dataUrl = await blobToDataUrl(blob);
const prepared = await prepareCodexAtlas(dataUrl);
patchCustom(
{
name: pet.displayName || pet.id,
greeting: pet.description || `Hi! I am ${pet.displayName}.`,
imageUrl: prepared.dataUrl,
// Atlas mode owns frame timing per row — clear the legacy
// strip fields so an older config rehydrating into the new
// shape does not accidentally fall back to strip rendering.
frames: 1,
fps: prepared.layout.rowsDef[0]?.fps ?? 6,
atlas: prepared.layout,
},
{ focusCustom: true },
);
const custom = await prepareCodexPetCustom(pet);
patchCustom(custom, { focusCustom: true });
setPetActionStatus({
kind: 'adopted',
name: pet.displayName || pet.id,
});
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Could not adopt that pet.';
setUploadError(message);
return false;
} finally {
setCodexAdopting(null);
}
@ -426,11 +426,35 @@ export function PetSettings({ cfg, setCfg }: Props) {
() => codexPets.filter((p) => !p.bundled),
[codexPets],
);
const selectedPetPreview = pet.adopted ? resolveActivePet(pet) : null;
const canToggleVisibility =
pet.adopted || bundledPets.length > 0 || codexPetsLoading;
async function togglePetVisibility() {
if (pet.enabled) {
update({ enabled: false });
setPetActionStatus({ kind: 'hidden' });
return;
}
if (pet.adopted) {
update({ enabled: true });
setPetActionStatus({ kind: 'shown' });
return;
}
const firstBundledPet = bundledPets[0];
if (firstBundledPet) {
const adopted = await adoptCodexPet(firstBundledPet);
if (adopted) setActiveTab('builtIn');
}
}
// Shared card renderer used by both the Built-in and Community tabs
// so the visual treatment stays consistent — the only difference
// between the two grids is which subset of `codexPets` they show.
function renderCodexCard(p: CodexPetSummary) {
function renderCodexCard(
p: CodexPetSummary,
options?: { defaultChoice?: boolean },
) {
const adopting = codexAdopting === p.id;
const spritesheet = `url(${codexPetSpritesheetUrl(p)})`;
// Best-effort match: bundled / community adoption copies the
@ -455,8 +479,17 @@ export function PetSettings({ cfg, setCfg }: Props) {
<span className="pet-codex-thumb-preview" aria-hidden />
</div>
<div className="pet-codex-meta">
<strong>{p.displayName}</strong>
{p.description ? <span>{p.description}</span> : null}
<span className="pet-codex-title-row">
<strong>{p.displayName}</strong>
{options?.defaultChoice ? (
<span className="pet-codex-default-badge">
{t('common.default')}
</span>
) : null}
</span>
{p.description ? (
<span className="pet-codex-description">{p.description}</span>
) : null}
</div>
<button
type="button"
@ -477,6 +510,39 @@ export function PetSettings({ cfg, setCfg }: Props) {
return (
<section className="settings-section">
{petActionStatus ? (
<p className="pet-action-status" role="status">
<Icon name="check" size={12} />
<span>
{petActionStatus.kind === 'adopted'
? `${t('pet.adoptedBadge')}: ${petActionStatus.name ?? ''}`
: petActionStatus.kind === 'shown'
? t('pet.wake')
: t('pet.tuck')}
</span>
</p>
) : null}
{selectedPetPreview ? (
<div
className="pet-current-summary"
style={{ ['--pet-accent' as string]: selectedPetPreview.accent }}
>
<span className="pet-current-summary__sprite" aria-hidden>
<PetSpriteFace active={selectedPetPreview} size={38} />
</span>
<div className="pet-current-summary__copy">
<span className="pet-current-summary__label">
{t('pet.adoptedBadge')}
</span>
<strong>{selectedPetPreview.name}</strong>
<span>
{pet.enabled ? t('pet.wake') : t('pet.tuck')} · {selectedPetPreview.greeting}
</span>
</div>
</div>
) : null}
<div className="pet-tabs">
<div className="pet-tabs-top-row">
<div
@ -516,11 +582,14 @@ export function PetSettings({ cfg, setCfg }: Props) {
<button
type="button"
className={`seg-btn small${pet.enabled ? ' active' : ''}`}
onClick={() => update({ enabled: !pet.enabled, adopted: pet.adopted || pet.petId !== '' })}
disabled={!pet.adopted}
onClick={() => void togglePetVisibility()}
disabled={!canToggleVisibility || codexAdopting !== null}
title={pet.enabled ? t('pet.tuckTitle') : t('pet.wakeTitle')}
>
<Icon name={pet.enabled ? 'eye' : 'sparkles'} size={14} />
<Icon
name={codexAdopting !== null ? 'spinner' : pet.enabled ? 'eye' : 'sparkles'}
size={14}
/>
<span>{pet.enabled ? t('pet.tuck') : t('pet.wake')}</span>
</button>
</div>
@ -548,7 +617,11 @@ export function PetSettings({ cfg, setCfg }: Props) {
role="radiogroup"
aria-label={t('pet.tabBuiltIn')}
>
{bundledPets.map(renderCodexCard)}
{bundledPets.map((p, index) =>
renderCodexCard(p, {
defaultChoice: !pet.adopted && index === 0,
}),
)}
</div>
)}
{uploadError ? (
@ -917,7 +990,7 @@ export function PetSettings({ cfg, setCfg }: Props) {
role="radiogroup"
aria-label={t('pet.codexTitle')}
>
{communityPets.map(renderCodexCard)}
{communityPets.map((p) => renderCodexCard(p))}
</div>
)}
</div>

View file

@ -1,4 +1,4 @@
import type { AppConfig, PetAtlasLayout, PetAtlasRowDef, PetCustom, PetConfig } from '../../types';
import type { AppConfig, CodexPetSummary, PetAtlasLayout, PetAtlasRowDef, PetCustom, PetConfig } from '../../types';
import {
codexPetSpritesheetUrl,
fetchCodexPets,
@ -323,6 +323,24 @@ export async function migrateCustomPetAtlas(
}
}
export async function prepareCodexPetCustom(pet: CodexPetSummary): Promise<PetCustom> {
const resp = await fetch(codexPetSpritesheetUrl(pet));
if (!resp.ok) throw new Error('Could not download that pet.');
const blob = await resp.blob();
const dataUrl = await blobToDataUrl(blob);
const prepared = await prepareCodexAtlas(dataUrl);
return {
name: pet.displayName || pet.id,
glyph: '🦄',
accent: '#c96442',
greeting: pet.description || `Hi! I am ${pet.displayName || pet.id}.`,
imageUrl: prepared.dataUrl,
frames: 1,
fps: prepared.layout.rowsDef[0]?.fps ?? 6,
atlas: prepared.layout,
};
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();

View file

@ -0,0 +1,70 @@
import type { ChatRunStatusResponse } from '@open-design/contracts';
import type { Project } from '../../types';
import type { PetRecentTaskSummary, PetTaskCenter, PetTaskSummary } from './PetOverlay';
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
export function buildPetTaskCenter(
projects: Project[],
runs: ChatRunStatusResponse[],
): PetTaskCenter {
const projectsById = new Map(projects.map((project) => [project.id, project]));
const running = new Map<string, PetTaskSummary>();
const queued = new Map<string, PetTaskSummary>();
const recentByProject = new Map<string, PetRecentTaskSummary>();
for (const run of runs) {
if (!run.projectId) continue;
const project = projectsById.get(run.projectId);
if (!project) continue;
if (run.status === 'running') {
addActiveSummary(running, run.projectId, project.name, 'running');
continue;
}
if (run.status === 'queued') {
addActiveSummary(queued, run.projectId, project.name, 'queued');
continue;
}
if (TERMINAL_STATUSES.has(run.status)) {
const prev = recentByProject.get(run.projectId);
if (prev && prev.updatedAt >= run.updatedAt) continue;
recentByProject.set(run.projectId, {
projectId: run.projectId,
projectName: project.name,
status: run.status as PetRecentTaskSummary['status'],
updatedAt: run.updatedAt,
});
}
}
return {
running: sortActiveSummaries([...running.values()]),
queued: sortActiveSummaries([...queued.values()]),
recent: [...recentByProject.values()]
.filter((task) => !running.has(task.projectId) && !queued.has(task.projectId))
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, 3),
};
}
function addActiveSummary(
summaries: Map<string, PetTaskSummary>,
projectId: string,
projectName: string,
status: PetTaskSummary['status'],
) {
const prev = summaries.get(projectId);
summaries.set(projectId, {
projectId,
projectName,
status,
count: (prev?.count ?? 0) + 1,
});
}
function sortActiveSummaries(summaries: PetTaskSummary[]): PetTaskSummary[] {
return summaries.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.projectName.localeCompare(b.projectName);
});
}

View file

@ -1707,7 +1707,7 @@ export const en: Dict = {
'Open a project to persist this server-side. Until then, this only changes the in-browser preference.',
'pet.title': 'Pets',
'pet.subtitle': 'Adopt a tiny companion that floats over your workspace.',
'pet.subtitle': 'Use Show pet to control whether the companion appears in your workspace. Choose a pet below.',
'pet.navTitle': 'Pets',
'pet.navHint': 'Adopt or customize',
'pet.tabBuiltIn': 'Built-in',
@ -1717,15 +1717,15 @@ export const en: Dict = {
'pet.tabCustomHint': 'Make your own with a name, glyph, color or sprite.',
'pet.tabCommunity': 'Community',
'pet.tabCommunityHint': 'Hatched pets from Codex — adopt one or generate a new one.',
'pet.tabsAria': 'Pet source',
'pet.tabsAria': 'Choose pet',
'pet.adopt': 'Adopt',
'pet.adoptedBadge': 'Adopted',
'pet.adoptCallout': 'Adopt a pet',
'pet.changePet': 'Change pet',
'pet.wake': 'Wake',
'pet.tuck': 'Tuck away',
'pet.wakeTitle': 'Wake the pet — show the floating overlay.',
'pet.tuckTitle': 'Tuck the pet away — hide the floating overlay.',
'pet.wake': 'Show pet',
'pet.tuck': 'Hide pet',
'pet.wakeTitle': 'Show the pet in the workspace. If none is selected, the first built-in pet will be used.',
'pet.tuckTitle': 'Hide the pet from the workspace.',
'pet.settingsTitle': 'Open pet settings',
'pet.useCustom': 'Use my pet',
'pet.customTitle': 'Make your own',
@ -1741,6 +1741,27 @@ export const en: Dict = {
'pet.overlayAria': 'Pet companion',
'pet.spriteAria': '{name} — drag to move, click to chat',
'pet.spriteTitle': 'Hi from {name}! Click to chat.',
'pet.taskSummarySingle': '{count} agent task is running.',
'pet.taskSummaryMultiple': '{count} agent tasks are running across {projects} projects.',
'pet.taskSummaryRecentSingle': '{count} agent task recently completed.',
'pet.taskSummaryRecentMultiple': '{count} agent tasks recently completed.',
'pet.taskListAria': 'Active agent tasks',
'pet.taskOpenProject': 'Open {project}',
'pet.taskGroup.running': 'Running',
'pet.taskGroup.queued': 'Waiting',
'pet.taskGroup.recent': 'Recently completed',
'pet.idleQuote.leonardo.text': 'Learning never exhausts the mind.',
'pet.idleQuote.leonardo.author': 'Leonardo da Vinci',
'pet.idleQuote.michelangelo.text': 'I saw the angel in the marble and carved until I set him free.',
'pet.idleQuote.michelangelo.author': 'Michelangelo',
'pet.idleQuote.bernini.text': 'There are two devices which can help the sculptor: light and shadow.',
'pet.idleQuote.bernini.author': 'Gian Lorenzo Bernini',
'pet.idleQuote.raphael.text': 'When one is painting one does not think.',
'pet.idleQuote.raphael.author': 'Raphael',
'pet.idleQuote.caravaggio.text': 'All works, no matter what or by whom painted, are nothing but bagatelles and childish trifles unless they are made and painted from life.',
'pet.idleQuote.caravaggio.author': 'Caravaggio',
'pet.idleQuote.rodin.text': 'The main thing is to be moved, to love, to hope, to tremble, to live.',
'pet.idleQuote.rodin.author': 'Auguste Rodin',
'pet.composerTitle': 'Pets — wake, tuck, or pick one',
'pet.composerMenuTitle': 'Pets',
'pet.composerMenuHint': 'tip: type /pet to toggle',

View file

@ -1680,18 +1680,18 @@ export const zhCN: Dict = {
'pet.tabCustomHint': '自己取名、选符号或上传精灵图。',
'pet.tabCommunity': '社区',
'pet.tabCommunityHint': '来自 Codex 的孵化宠物 — 领养或用 AI 生成新的。',
'pet.tabsAria': '宠物来源',
'pet.subtitle': '领养一只小宠物,让它陪你一起设计。',
'pet.tabsAria': '选择宠物',
'pet.subtitle': '「显示宠物」控制它是否出现在工作区;下方可以选择内置、自定义或社区宠物。',
'pet.navTitle': '宠物',
'pet.navHint': '领养与自定义',
'pet.adopt': '领养',
'pet.adoptedBadge': '已领养',
'pet.adoptCallout': '领养一只宠物',
'pet.changePet': '更换宠物',
'pet.wake': '唤醒',
'pet.tuck': '收起',
'pet.wakeTitle': '唤醒宠物 — 显示浮窗。',
'pet.tuckTitle': '收起宠物 — 隐藏浮窗。',
'pet.wake': '显示宠物',
'pet.tuck': '隐藏宠物',
'pet.wakeTitle': '在工作区显示宠物。尚未选择时,会默认使用第一只内置宠物。',
'pet.tuckTitle': '从工作区隐藏宠物。',
'pet.settingsTitle': '打开宠物设置',
'pet.useCustom': '使用我的宠物',
'pet.customTitle': '自定义你的宠物',
@ -1707,6 +1707,27 @@ export const zhCN: Dict = {
'pet.overlayAria': '宠物伙伴',
'pet.spriteAria': '{name} — 拖动可移动,点击与它互动',
'pet.spriteTitle': '{name} 来打招呼啦!点击聊天。',
'pet.taskSummarySingle': '正在执行 {count} 个 Agent 任务。',
'pet.taskSummaryMultiple': '正在执行 {count} 个 Agent 任务,分布在 {projects} 个项目。',
'pet.taskSummaryRecentSingle': '最近完成 {count} 个 Agent 任务。',
'pet.taskSummaryRecentMultiple': '最近完成 {count} 个 Agent 任务。',
'pet.taskListAria': '执行中的 Agent 任务',
'pet.taskOpenProject': '打开 {project}',
'pet.taskGroup.running': '正在执行',
'pet.taskGroup.queued': '等待中',
'pet.taskGroup.recent': '最近完成',
'pet.idleQuote.leonardo.text': '学习从不会使心灵疲惫。',
'pet.idleQuote.leonardo.author': '列奥纳多·达·芬奇',
'pet.idleQuote.michelangelo.text': '我看见天使在大理石中,于是不断雕刻,直到让他自由。',
'pet.idleQuote.michelangelo.author': '米开朗基罗',
'pet.idleQuote.bernini.text': '有两样东西能帮助雕塑家:光与影。',
'pet.idleQuote.bernini.author': '吉安·洛伦佐·贝尼尼',
'pet.idleQuote.raphael.text': '作画时,人并不思考。',
'pet.idleQuote.raphael.author': '拉斐尔',
'pet.idleQuote.caravaggio.text': '作品若不是从生活中来,终究只是细枝末节。',
'pet.idleQuote.caravaggio.author': '卡拉瓦乔',
'pet.idleQuote.rodin.text': '最重要的是被打动、去爱、去希望、去颤抖、去生活。',
'pet.idleQuote.rodin.author': '奥古斯特·罗丹',
'pet.composerTitle': '宠物 — 唤醒、收起或选一只',
'pet.composerMenuTitle': '宠物',
'pet.composerMenuHint': '小贴士:输入 /pet 即可切换',

View file

@ -1258,18 +1258,18 @@ export const zhTW: Dict = {
'pet.tabCustomHint': '自己命名、選符號或上傳精靈圖。',
'pet.tabCommunity': '社群',
'pet.tabCommunityHint': '來自 Codex 的孵化寵物 — 領養或用 AI 生成新的。',
'pet.tabsAria': '寵物來源',
'pet.subtitle': '領養一隻小寵物,讓它陪你一起設計。',
'pet.tabsAria': '選擇寵物',
'pet.subtitle': '「顯示寵物」控制它是否出現在工作區;下方可以選擇內建、自訂或社群寵物。',
'pet.navTitle': '寵物',
'pet.navHint': '領養與自訂',
'pet.adopt': '領養',
'pet.adoptedBadge': '已領養',
'pet.adoptCallout': '領養一隻寵物',
'pet.changePet': '更換寵物',
'pet.wake': '喚醒',
'pet.tuck': '收起',
'pet.wakeTitle': '喚醒寵物 — 顯示浮窗。',
'pet.tuckTitle': '收起寵物 — 隱藏浮窗。',
'pet.wake': '顯示寵物',
'pet.tuck': '隱藏寵物',
'pet.wakeTitle': '在工作區顯示寵物。尚未選擇時,會預設使用第一隻內建寵物。',
'pet.tuckTitle': '從工作區隱藏寵物。',
'pet.settingsTitle': '開啟寵物設定',
'pet.useCustom': '使用我的寵物',
'pet.customTitle': '自訂你的寵物',
@ -1285,6 +1285,27 @@ export const zhTW: Dict = {
'pet.overlayAria': '寵物夥伴',
'pet.spriteAria': '{name} — 拖曳可移動,點擊與它互動',
'pet.spriteTitle': '{name} 來打招呼啦!點擊聊天。',
'pet.taskSummarySingle': '正在執行 {count} 個 Agent 任務。',
'pet.taskSummaryMultiple': '正在執行 {count} 個 Agent 任務,分布在 {projects} 個專案。',
'pet.taskSummaryRecentSingle': '最近完成 {count} 個 Agent 任務。',
'pet.taskSummaryRecentMultiple': '最近完成 {count} 個 Agent 任務。',
'pet.taskListAria': '執行中的 Agent 任務',
'pet.taskOpenProject': '開啟 {project}',
'pet.taskGroup.running': '正在執行',
'pet.taskGroup.queued': '等待中',
'pet.taskGroup.recent': '最近完成',
'pet.idleQuote.leonardo.text': '學習從不會使心靈疲憊。',
'pet.idleQuote.leonardo.author': '李奧納多·達文西',
'pet.idleQuote.michelangelo.text': '我看見天使在大理石中,於是不斷雕刻,直到讓他自由。',
'pet.idleQuote.michelangelo.author': '米開朗基羅',
'pet.idleQuote.bernini.text': '有兩樣東西能幫助雕塑家:光與影。',
'pet.idleQuote.bernini.author': '吉安·洛倫佐·貝尼尼',
'pet.idleQuote.raphael.text': '作畫時,人並不思考。',
'pet.idleQuote.raphael.author': '拉斐爾',
'pet.idleQuote.caravaggio.text': '作品若不是從生活中來,終究只是細枝末節。',
'pet.idleQuote.caravaggio.author': '卡拉瓦喬',
'pet.idleQuote.rodin.text': '最重要的是被打動、去愛、去希望、去顫抖、去生活。',
'pet.idleQuote.rodin.author': '奧古斯特·羅丹',
'pet.composerTitle': '寵物 — 喚醒、收起或挑一隻',
'pet.composerMenuTitle': '寵物',
'pet.composerMenuHint': '小提示:輸入 /pet 即可切換',

View file

@ -1942,6 +1942,27 @@ export interface Dict {
'pet.overlayAria': string;
'pet.spriteAria': string;
'pet.spriteTitle': string;
'pet.taskSummarySingle': string;
'pet.taskSummaryMultiple': string;
'pet.taskSummaryRecentSingle': string;
'pet.taskSummaryRecentMultiple': string;
'pet.taskListAria': string;
'pet.taskOpenProject': string;
'pet.taskGroup.running': string;
'pet.taskGroup.queued': string;
'pet.taskGroup.recent': string;
'pet.idleQuote.leonardo.text': string;
'pet.idleQuote.leonardo.author': string;
'pet.idleQuote.michelangelo.text': string;
'pet.idleQuote.michelangelo.author': string;
'pet.idleQuote.bernini.text': string;
'pet.idleQuote.bernini.author': string;
'pet.idleQuote.raphael.text': string;
'pet.idleQuote.raphael.author': string;
'pet.idleQuote.caravaggio.text': string;
'pet.idleQuote.caravaggio.author': string;
'pet.idleQuote.rodin.text': string;
'pet.idleQuote.rodin.author': string;
// Composer pet menu
'pet.composerTitle': string;
'pet.composerMenuTitle': string;

View file

@ -16965,6 +16965,25 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
transform: translateX(-50%);
animation: pet-shadow 3.4s ease-in-out infinite;
}
.pet-sprite-status {
position: absolute;
right: 8px;
top: 8px;
min-width: 20px;
height: 20px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
background: var(--pet-accent);
color: white;
border: 2px solid var(--bg);
font-size: 11px;
font-weight: 700;
line-height: 1;
box-shadow: var(--shadow-sm);
}
@keyframes pet-bounce {
0%, 100% { transform: translateY(0); }
@ -17027,6 +17046,111 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
.pet-bubble-line {
color: var(--text);
}
.pet-idle-quote {
margin: 0;
display: grid;
gap: 5px;
}
.pet-idle-quote blockquote {
margin: 0;
color: var(--text);
font-size: 12.5px;
line-height: 1.45;
}
.pet-idle-quote figcaption {
color: var(--text-muted);
font-size: 11px;
text-align: right;
}
.pet-idle-quote figcaption::before {
content: '— ';
}
.pet-task-list {
margin-top: 8px;
display: grid;
gap: 8px;
}
.pet-task-group {
display: grid;
gap: 5px;
}
.pet-task-group-title {
color: var(--text-faint);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pet-task-item {
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
padding: 5px 7px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text);
text-align: left;
cursor: pointer;
}
.pet-task-item:hover {
background: var(--bg-muted);
border-color: var(--border-strong);
}
.pet-task-item--static {
cursor: default;
}
.pet-task-item--static:hover {
background: var(--bg-subtle);
border-color: var(--border);
}
.pet-task-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--pet-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pet-accent) 18%, transparent);
}
.pet-task-dot[data-pet-task-status="queued"] {
opacity: 0.62;
}
.pet-task-dot[data-pet-task-status="succeeded"] {
background: var(--success);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 18%, transparent);
}
.pet-task-dot[data-pet-task-status="failed"] {
background: var(--danger);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent);
}
.pet-task-dot[data-pet-task-status="canceled"] {
background: var(--text-faint);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--text-faint) 18%, transparent);
}
.pet-task-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11.5px;
font-weight: 600;
}
.pet-task-count {
min-width: 18px;
height: 18px;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-pill);
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border);
font-size: 10.5px;
font-weight: 700;
}
.pet-bubble-actions {
margin-top: 8px;
display: flex;
@ -17051,6 +17175,25 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
border-color: var(--border-strong);
}
body.desktop-pet-shell {
width: 100vw;
height: 100vh;
margin: 0;
overflow: hidden;
background: transparent;
}
body.desktop-pet-shell .pet-overlay {
right: 18px !important;
bottom: 18px !important;
}
body.desktop-pet-shell .pet-bubble,
body.desktop-pet-shell .pet-sprite {
-webkit-app-region: drag;
}
body.desktop-pet-shell .pet-task-item {
-webkit-app-region: no-drag;
}
@keyframes pet-bubble-in {
from { opacity: 0; transform: translateY(4px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
@ -17134,6 +17277,89 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
align-items: center;
gap: 6px;
}
.pet-source-head {
margin-top: 14px;
display: grid;
gap: 4px;
}
.pet-source-head h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
}
.pet-source-head .hint {
margin: 0;
}
.pet-action-status {
margin: 10px 0 0;
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
max-width: 100%;
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
background: color-mix(in srgb, var(--accent) 9%, var(--bg-panel));
color: var(--accent-strong);
font-size: 12px;
line-height: 1.35;
}
.pet-action-status span {
min-width: 0;
overflow-wrap: anywhere;
}
.pet-current-summary {
margin-top: 10px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
}
.pet-current-summary__sprite {
width: 42px;
height: 42px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: color-mix(in srgb, var(--pet-accent, var(--accent)) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--pet-accent, var(--accent)) 30%, var(--border));
}
.pet-current-summary__copy {
display: grid;
gap: 1px;
min-width: 0;
}
.pet-current-summary__label {
color: var(--text-faint);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pet-current-summary__copy strong {
min-width: 0;
color: var(--text-strong);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-current-summary__copy span:last-child {
min-width: 0;
color: var(--text-muted);
font-size: 11.5px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.seg-btn.small {
padding: 4px 10px !important;
font-size: 12px !important;
@ -18333,6 +18559,13 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
padding-right: 8px;
}
.pet-codex-title-row {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pet-codex-meta strong {
font-size: 12px;
font-weight: 600;
@ -18342,7 +18575,19 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
text-overflow: ellipsis;
}
.pet-codex-meta span {
.pet-codex-default-badge {
flex: none;
padding: 1px 6px;
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent-strong);
font-size: 9.5px;
font-weight: 600;
line-height: 1.4;
}
.pet-codex-description {
font-size: 11px;
color: var(--text-muted);
display: -webkit-box;

View file

@ -195,6 +195,13 @@ export interface DaemonReattachOptions {
onRunEventId?: (eventId: string) => void;
}
export const RUNS_CHANGED_EVENT = 'open-design:runs-changed';
function notifyRunsChanged() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(RUNS_CHANGED_EVENT));
}
function daemonSseErrorMessage(data: SseErrorPayload): string {
const message = String(data.error?.message ?? data.message ?? 'daemon error');
const detail =
@ -231,6 +238,10 @@ export async function streamViaDaemon({
onRunStatus,
onRunEventId,
}: DaemonStreamOptions): Promise<void> {
const emitRunStatus = (status: ChatRunStatus) => {
onRunStatus?.(status);
notifyRunsChanged();
};
// Local CLIs are single-turn print-mode programs, so we collapse the whole
// chat into one string. If this becomes too noisy for long histories, the
// fix is to only include the final user turn.
@ -270,7 +281,7 @@ export async function streamViaDaemon({
if (!createResp.ok) {
const text = await createResp.text().catch(() => '');
onRunStatus?.('failed');
emitRunStatus('failed');
handlers.onError(new Error(`daemon ${createResp.status}: ${text || 'no body'}`));
return;
}
@ -278,25 +289,32 @@ export async function streamViaDaemon({
const created = (await createResp.json()) as ChatRunCreateResponse;
const runId = created.runId;
onRunCreated?.(runId);
onRunStatus?.('queued');
notifyRunsChanged();
emitRunStatus('queued');
await consumeDaemonRun({
runId,
signal,
cancelSignal,
handlers,
initialLastEventId,
onRunStatus,
onRunStatus: emitRunStatus,
onRunEventId,
});
} catch (err) {
if ((err as Error).name === 'AbortError') return;
onRunStatus?.('failed');
emitRunStatus('failed');
handlers.onError(err instanceof Error ? err : new Error(String(err)));
}
}
export async function reattachDaemonRun(options: DaemonReattachOptions): Promise<void> {
await consumeDaemonRun(options);
await consumeDaemonRun({
...options,
onRunStatus: (status) => {
options.onRunStatus?.(status);
notifyRunsChanged();
},
});
}
export async function fetchChatRunStatus(runId: string): Promise<ChatRunStatusResponse | null> {
@ -348,6 +366,17 @@ export async function listActiveChatRuns(
}
}
export async function listProjectRuns(): Promise<ChatRunStatusResponse[]> {
try {
const resp = await fetch('/api/runs');
if (!resp.ok) return [];
const body = (await resp.json()) as ChatRunListResponse;
return body.runs ?? [];
} catch {
return [];
}
}
async function consumeDaemonRun({
runId,
signal,

View file

@ -48,6 +48,7 @@ declare global {
// failure (Electron's shell.openPath contract, plus PR #974
// trust-boundary failures).
openPath?: (projectId: string) => Promise<string>;
setDesktopPetVisible?: (visible: boolean) => void;
};
}
}

View file

@ -0,0 +1,89 @@
import type { Project } from '../types';
const MAX_CJK_TITLE_LENGTH = 18;
const MAX_LATIN_WORDS = 6;
const CJK_PATTERN = /[\u3400-\u9fff]/;
const LEADING_CJK_FILLER = [
/^先?(帮我|帮忙|麻烦|请|可以|能不能|能否|给我|我想要|我要)/,
/^(先)?(实现|做|做一下|创建|生成|设计|开发|新增|添加|优化|修复|改|更改|调整)(一下|一个|一版|下)?/,
/^(一个|一份|这个|那个)/,
];
const LEADING_LATIN_FILLER =
/^(please\s+)?(can\s+you\s+|could\s+you\s+|help\s+me\s+|i\s+want\s+to\s+|i\s+need\s+to\s+)?(create|build|make|design|implement|add|fix|update|improve|optimize|generate|write)\s+(a|an|the|this|that)?\s*/i;
const LATIN_STOP_WORDS = new Set([
'a',
'an',
'and',
'for',
'in',
'of',
'on',
'please',
'the',
'to',
'with',
]);
function cleanPrompt(prompt: string): string {
return prompt
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`]*`/g, ' ')
.replace(/https?:\/\/\S+/g, ' ')
.replace(/[@#][\w.-]+/g, ' ')
.replace(/[“”"']/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function trimCjkTitle(input: string): string {
let title = input.trim();
for (const pattern of LEADING_CJK_FILLER) {
title = title.replace(pattern, '').trim();
}
if (/项目名称/.test(title) && /自动/.test(title) && /(更改|修改|命名)/.test(title)) {
return '自动项目命名';
}
title = title
.replace(/^根据项目中的?第一个\s*prompt\s*/i, '')
.replace(/项目名称.*自动.*(更改|修改|命名)/, '自动项目命名')
.replace(/自动.*(更改|修改).*项目名称/, '自动项目命名')
.replace(/总结项目名称/, '项目命名')
.replace(/[,。!?;:,.!?;:].*$/, '')
.replace(/\s+/g, '');
if (!title) return '';
return title.slice(0, MAX_CJK_TITLE_LENGTH);
}
function toTitleCase(word: string): string {
if (!word) return word;
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
function trimLatinTitle(input: string): string {
const words = input
.replace(LEADING_LATIN_FILLER, '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.filter((word) => !LATIN_STOP_WORDS.has(word.toLowerCase()))
.slice(0, MAX_LATIN_WORDS);
return words.map(toTitleCase).join(' ');
}
export function summarizeProjectNameFromPrompt(prompt: string): string {
const cleaned = cleanPrompt(prompt);
if (!cleaned) return '';
const firstClause = cleaned.split(/[\n\r。!?]/)[0]?.trim() ?? cleaned;
if (CJK_PATTERN.test(firstClause)) return trimCjkTitle(firstClause);
return trimLatinTitle(firstClause);
}
export function canAutoRenameProjectFromPrompt(
project: Pick<Project, 'metadata'>,
): boolean {
return project.metadata?.nameSource === 'generated';
}

View file

@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { PetOverlay, type PetTaskCenter } from '../../src/components/pet/PetOverlay';
import type { PetConfig } from '../../src/types';
const pet: PetConfig = {
adopted: true,
enabled: true,
petId: 'custom',
custom: {
name: 'YoRHa Sit-2B',
glyph: 'N',
accent: '#df6a45',
greeting: 'Ready.',
},
};
const recentOnly: PetTaskCenter = {
running: [],
queued: [],
recent: [
{
projectId: 'p1',
projectName: 'Web Prototype',
status: 'succeeded',
updatedAt: 100,
},
],
};
function tapPet() {
const sprite = screen.getByLabelText(/YoRHa Sit-2B/);
fireEvent.pointerDown(sprite, {
button: 0,
clientX: 0,
clientY: 0,
pointerId: 1,
});
fireEvent.pointerUp(sprite, {
button: 0,
clientX: 0,
clientY: 0,
pointerId: 1,
});
}
beforeEach(() => {
HTMLElement.prototype.setPointerCapture = () => {};
HTMLElement.prototype.releasePointerCapture = () => {};
});
afterEach(() => {
cleanup();
});
describe('PetOverlay recent task acknowledgement', () => {
it('shows recent completions immediately in persistent bubble mode', () => {
const { container } = render(
<PetOverlay pet={pet} taskCenter={recentOnly} persistentBubble />,
);
expect(container.querySelector('.pet-sprite-status')?.textContent).toBe('1');
expect(screen.getByText('Recently completed')).not.toBeNull();
expect(screen.getByText('Web Prototype')).not.toBeNull();
});
it('clears the recent completion badge after the user opens it and hides it after closing', () => {
const { container } = render(<PetOverlay pet={pet} taskCenter={recentOnly} />);
expect(container.querySelector('.pet-sprite-status')?.textContent).toBe('1');
tapPet();
expect(container.querySelector('.pet-sprite-status')).toBeNull();
expect(screen.getByText('Recently completed')).not.toBeNull();
expect(screen.getByText('Web Prototype')).not.toBeNull();
tapPet();
expect(screen.queryByText('Recently completed')).toBeNull();
tapPet();
expect(container.querySelector('.pet-sprite-status')).toBeNull();
expect(screen.queryByText('Recently completed')).toBeNull();
expect(screen.queryByText('Web Prototype')).toBeNull();
});
});

View file

@ -416,6 +416,37 @@ describe('ProjectView conversation run isolation', () => {
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
});
it('does not rename an existing named project when sending the first message in an empty conversation', async () => {
const namedProject: Project = {
...project,
name: 'Imported Client Folder',
metadata: { kind: 'prototype', nameSource: 'user' },
};
const emptyConversation: Conversation = {
id: 'conv-empty',
projectId: namedProject.id,
title: null,
createdAt: 1,
updatedAt: 1,
};
listConversations.mockResolvedValue([emptyConversation]);
listMessages.mockResolvedValue([]);
fetchChatRunStatus.mockResolvedValue(null);
renderProjectView(config, namedProject);
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-empty'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(patchProject).not.toHaveBeenCalledWith(
namedProject.id,
expect.objectContaining({ name: expect.any(String) }),
);
});
it('notifies when an API-mode chat completes without a daemon run status transition', async () => {
listMessages.mockResolvedValue([]);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
@ -449,10 +480,10 @@ describe('ProjectView conversation run isolation', () => {
});
});
function renderProjectView(renderConfig = config) {
function renderProjectView(renderConfig = config, renderProject: Project = project) {
return render(
<ProjectView
project={project}
project={renderProject}
routeFileName={null}
config={renderConfig}
agents={[{ id: 'agent-1', name: 'OpenCode', bin: 'opencode', available: true, models: [] }]}

View file

@ -1894,7 +1894,7 @@ describe('SettingsDialog pets interactions', () => {
{ initialSection: 'pet' },
);
expect((screen.getByRole('button', { name: 'Wake' }) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByRole('button', { name: 'Show pet' }) as HTMLButtonElement).disabled).toBe(false);
await waitFor(() => {
expect(screen.getByText('Dario')).toBeTruthy();
@ -1973,9 +1973,10 @@ describe('SettingsDialog pets interactions', () => {
{ initialSection: 'pet' },
);
const toggle = screen.getByRole('button', { name: 'Tuck away' });
const toggle = screen.getByRole('button', { name: 'Hide pet' });
fireEvent.click(toggle);
expect(screen.getByRole('button', { name: 'Wake' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Show pet' })).toBeTruthy();
expect(screen.getByText('Hide pet')).toBeTruthy();
await waitForPersist(
onPersist,

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import type { ChatRunStatusResponse } from '@open-design/contracts';
import { buildPetTaskCenter } from '../../src/components/pet/taskCenter';
import type { Project } from '../../src/types';
const projects: Project[] = [
{ id: 'p1', name: 'Landing Page', skillId: null, designSystemId: null, createdAt: 1, updatedAt: 1 },
{ id: 'p2', name: 'Brand Deck', skillId: null, designSystemId: null, createdAt: 1, updatedAt: 1 },
{ id: 'p3', name: 'Ignored', skillId: null, designSystemId: null, createdAt: 1, updatedAt: 1 },
];
function run(
id: string,
projectId: string | null,
status: ChatRunStatusResponse['status'],
updatedAt: number,
): ChatRunStatusResponse {
return {
id,
projectId,
conversationId: null,
assistantMessageId: null,
agentId: null,
status,
createdAt: updatedAt - 10,
updatedAt,
};
}
describe('buildPetTaskCenter', () => {
it('groups active runs and hides recent completions for active projects', () => {
const center = buildPetTaskCenter(projects, [
run('r1', 'p1', 'running', 10),
run('r2', 'p1', 'running', 12),
run('q1', 'p2', 'queued', 11),
run('done-old', 'p1', 'succeeded', 8),
run('done-new', 'p2', 'failed', 20),
run('no-project', null, 'running', 30),
run('missing-project', 'missing', 'running', 31),
]);
expect(center.running).toEqual([
{ projectId: 'p1', projectName: 'Landing Page', status: 'running', count: 2 },
]);
expect(center.queued).toEqual([
{ projectId: 'p2', projectName: 'Brand Deck', status: 'queued', count: 1 },
]);
expect(center.recent).toEqual([]);
});
it('keeps only the latest completed run per inactive project', () => {
const center = buildPetTaskCenter(projects, [
run('old-success', 'p1', 'succeeded', 10),
run('new-failure', 'p1', 'failed', 20),
run('brand-success', 'p2', 'succeeded', 15),
]);
expect(center.recent).toEqual([
{ projectId: 'p1', projectName: 'Landing Page', status: 'failed', updatedAt: 20 },
{ projectId: 'p2', projectName: 'Brand Deck', status: 'succeeded', updatedAt: 15 },
]);
});
});

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
canAutoRenameProjectFromPrompt,
summarizeProjectNameFromPrompt,
} from '../../src/utils/projectName';
describe('summarizeProjectNameFromPrompt', () => {
it('summarizes Chinese first prompts into concise project names', () => {
expect(
summarizeProjectNameFromPrompt('先实现一下根据项目中的第一个prompt总结项目名称并自动更改项目名称'),
).toBe('自动项目命名');
});
it('drops common English request prefixes', () => {
expect(
summarizeProjectNameFromPrompt('Please build a settings page for managing desktop pets'),
).toBe('Settings Page Managing Desktop Pets');
});
it('ignores code blocks and links before naming', () => {
expect(
summarizeProjectNameFromPrompt('Create a dashboard for https://example.com\n```ts\nconst x = 1\n```'),
).toBe('Dashboard');
});
it('only allows first-prompt renaming for generated project names', () => {
expect(
canAutoRenameProjectFromPrompt({
metadata: { kind: 'prototype', nameSource: 'generated' },
}),
).toBe(true);
expect(
canAutoRenameProjectFromPrompt({
metadata: { kind: 'prototype', nameSource: 'user' },
}),
).toBe(false);
expect(canAutoRenameProjectFromPrompt({ metadata: undefined })).toBe(false);
});
});

View file

@ -94,6 +94,10 @@ export interface ProjectMetadata {
templateLabel?: string;
/** Primary target surface selected at project creation. */
platform?: ProjectPlatform;
// Tracks whether the visible name was typed by the user or generated by
// the create flow. First-prompt auto-naming may only replace generated
// names so named imports/templates stay stable across conversations.
nameSource?: 'generated' | 'prompt' | 'user';
/** Concrete delivery surfaces the artifact must account for. `responsive` is a web breakpoint target, not a native app expansion. */
platformTargets?: ProjectPlatform[];
inspirationDesignSystemIds?: string[];