From a7621d74d18c30c1245076924b01ab22f810d043 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 10:30:39 +0530 Subject: [PATCH 01/42] fix(terminal): a burst of grid changes reaches the pty as at most one resize A pty resize is not free on the other end: Codex answers any change of rows or cols by replaying its whole transcript, and the terminal's host changes height whenever the composer under it does, as well as on every tick of a window or splitter drag. Every renderer side resize now goes through one coalescer per pty (ptyResizeCoalescer.ts): the pty is told only once the grid has been quiet for 150 ms, and only if the settled grid differs from the one the burst started from. A disposed terminal drops its pending resize. The font size refit compares the grid before and after, like the other fits. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- .../src/components/PtyTerminalView.tsx | 7 +- .../src/components/ptyResizeCoalescer.ts | 73 +++++++++++++++++++ src/renderer/src/components/terminalPool.ts | 21 +++++- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/ptyResizeCoalescer.ts diff --git a/src/renderer/src/components/PtyTerminalView.tsx b/src/renderer/src/components/PtyTerminalView.tsx index b9da3732f..6eac8ab64 100644 --- a/src/renderer/src/components/PtyTerminalView.tsx +++ b/src/renderer/src/components/PtyTerminalView.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { CSSProperties } from 'react'; import '@xterm/xterm/css/xterm.css'; import { Icon } from './Icon'; -import { acquireTerminal, attachTerminal, detachTerminal, reflowTerminal } from './terminalPool'; +import { acquireTerminal, attachTerminal, detachTerminal, reflowTerminal, requestPtyResize } from './terminalPool'; import { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, @@ -178,7 +178,7 @@ export function PtyTerminalView({ ptyId, onStreamData, onUserPrompt, onToggleFul // 60ms, 240ms, font-load) used to stack the boot banner three times // before the user ever typed anything. if (entry.term.cols !== before.cols || entry.term.rows !== before.rows) { - window.cth.resizePty(ptyId, entry.term.cols, entry.term.rows); + requestPtyResize(ptyId, before, { cols: entry.term.cols, rows: entry.term.rows }); } entry.term.refresh(0, Math.max(0, entry.term.rows - 1)); initialFitDone = true; @@ -283,8 +283,9 @@ export function PtyTerminalView({ ptyId, onStreamData, onUserPrompt, onToggleFul const entry = acquireTerminal(ptyId, THEMES[ptyThemeRef.current], fontSize); entry.term.options.fontSize = fontSize; try { + const before = { cols: entry.term.cols, rows: entry.term.rows }; entry.fit.fit(); - window.cth.resizePty(ptyId, entry.term.cols, entry.term.rows); + requestPtyResize(ptyId, before, { cols: entry.term.cols, rows: entry.term.rows }); } catch { /* host may not be sized yet */ } }, [fontSize, ptyId]); diff --git a/src/renderer/src/components/ptyResizeCoalescer.ts b/src/renderer/src/components/ptyResizeCoalescer.ts new file mode 100644 index 000000000..c482b8b81 --- /dev/null +++ b/src/renderer/src/components/ptyResizeCoalescer.ts @@ -0,0 +1,73 @@ +// Ported from Prozzta/guppyos 02d0d50b (MIT). +/** + * Settle a burst of terminal grid changes into AT MOST ONE pty resize. + * + * LAG-150: a pty resize is not free on the other end. Codex (0.154) answers ANY change of + * rows or cols by replaying its whole transcript — measured ~90 KB for ~120 lines of + * history and ~280 KB for ~360, linear, never clearing scrollback — and every byte of it + * goes back through main, IPC and xterm. The terminal's host changes height whenever the + * composer under it changes height (a message queued to a busy agent shows the pending + * list; delivering it hides the list again), and the ResizeObserver fitted and resized + * the pty on every one of those, as well as on every tick of a window/splitter drag. + * + * `request(from, to)` is called after each local xterm fit that changed the grid. The pty + * is told only once the grid has been quiet for `settleMs`, and only if the settled grid + * differs from the grid the burst STARTED from — so a transient 19 -> 14 -> 19 rows costs + * the agent nothing, and a 40-tick drag costs one replay instead of forty. + * + * Pure, with its timers injected, so the rule can be tested under node. + */ +export interface Grid { cols: number; rows: number } + +export interface ResizeTimerHost { + set: (fn: () => void, ms: number) => unknown; + clear: (handle: unknown) => void; +} + +/** Long enough to swallow a layout flip and a drag's tick train, short enough that a + * deliberate resize still feels immediate. */ +export const PTY_RESIZE_SETTLE_MS = 150; + +export interface PtyResizeCoalescer { + /** A local fit moved the xterm grid from `from` to `to`. */ + request(from: Grid, to: Grid): void; + /** Send now if a settled change is pending (used on dispose so nothing is lost). */ + flush(): void; + /** Drop anything pending without sending. */ + cancel(): void; + readonly pending: boolean; +} + +export function createPtyResizeCoalescer( + send: (grid: Grid) => void, + host: ResizeTimerHost = { set: (fn, t) => setTimeout(fn, t), clear: (h) => clearTimeout(h as ReturnType) }, + settleMs: number = PTY_RESIZE_SETTLE_MS +): PtyResizeCoalescer { + let handle: unknown = null; + let origin: Grid | null = null; // the grid the pty had when this burst began + let latest: Grid | null = null; + + const flush = (): void => { + if (handle !== null) { host.clear(handle); handle = null; } + const from = origin, to = latest; + origin = null; latest = null; + if (!from || !to) return; + if (from.cols === to.cols && from.rows === to.rows) return; // settled back where it started + send({ cols: to.cols, rows: to.rows }); + }; + + return { + request(from: Grid, to: Grid): void { + if (origin === null) origin = { cols: from.cols, rows: from.rows }; + latest = { cols: to.cols, rows: to.rows }; + if (handle !== null) host.clear(handle); + handle = host.set(flush, settleMs); + }, + flush, + cancel(): void { + if (handle !== null) { host.clear(handle); handle = null; } + origin = null; latest = null; + }, + get pending(): boolean { return handle !== null; } + }; +} diff --git a/src/renderer/src/components/terminalPool.ts b/src/renderer/src/components/terminalPool.ts index 10f26c65f..5857f086b 100644 --- a/src/renderer/src/components/terminalPool.ts +++ b/src/renderer/src/components/terminalPool.ts @@ -39,6 +39,7 @@ import { type TerminalAutomationBlock } from './terminalAutomation'; import { sanitizeTerminalSelection } from './terminalSelection'; +import { createPtyResizeCoalescer, type Grid, type PtyResizeCoalescer } from './ptyResizeCoalescer'; import { terminalKeySequence } from './terminalKeys'; import '@xterm/xterm/css/xterm.css'; @@ -852,12 +853,28 @@ export function reflowTerminal(ptyId: string): void { // Only poke the pty when the grid actually changed (every resize repaints // the TUI and pushes a frame into scrollback). if (entry.term.cols !== before.cols || entry.term.rows !== before.rows) { - window.cth.resizePty(ptyId, entry.term.cols, entry.term.rows); + requestPtyResize(ptyId, before, { cols: entry.term.cols, rows: entry.term.rows }); } entry.term.refresh(0, Math.max(0, entry.term.rows - 1)); } catch { /* host may not be sized yet */ } } +// One coalescer per pty, so a burst of local fits reaches the pty as at most one resize, +// and none at all if the grid settles back where it started. See ptyResizeCoalescer.ts +// for why a pty resize is expensive (Codex replays its transcript). +const resizeCoalescers = new Map(); + +/** A local xterm fit moved this pty's grid from `from` to `to`: tell the pty once it + * settles. Every renderer-side pty resize goes through here. */ +export function requestPtyResize(ptyId: string, from: Grid, to: Grid): void { + let c = resizeCoalescers.get(ptyId); + if (!c) { + c = createPtyResizeCoalescer((g) => { void window.cth.resizePty(ptyId, g.cols, g.rows); }); + resizeCoalescers.set(ptyId, c); + } + c.request(from, to); +} + /** * Soft-reset a pooled terminal for an IN-PLACE pty respawn (the same ptyId is * reused — e.g. a model change or agent restart). Clears the screen + scrollback @@ -910,6 +927,8 @@ export function disposeTerminal(ptyId: string): void { try { entry.term.dispose(); } catch { /* noop */ } entry.host.remove(); pool.delete(ptyId); + resizeCoalescers.get(ptyId)?.cancel(); // the pty is going away: a late resize has no target + resizeCoalescers.delete(ptyId); } // ─── v0.3.4: ⌘-click a path in terminal output ────────────────────────────── From 120e23127160953e555dd6bcf43f211ce15f8059 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 10:31:19 +0530 Subject: [PATCH 02/42] fix(theme): dropdowns read in the dark theme A native select takes the app's text colour, near-white in the dark theme, but Chromium painted its box and its option list from the light UA scheme the app pins, so the dark theme showed white text on white. The dark theme now declares the dark UA scheme, and every select, option and optgroup takes its surface and text from the theme tokens (paper-100, ink-900; a disabled option ink-500), matching the other inputs in both themes. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/design/global.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/renderer/src/design/global.css b/src/renderer/src/design/global.css index 91d2785d1..45dad03e4 100644 --- a/src/renderer/src/design/global.css +++ b/src/renderer/src/design/global.css @@ -35,6 +35,19 @@ button, input, textarea, select { color: inherit; } +/* A native + {showOfficeCharacterPicker && (
{OFFICE_CAST.map((c) => { @@ -163,6 +180,16 @@ export function EditAgentModal({ agent, onClose }: EditAgentModalProps) { })}
+ )} + + {looks && lookDraft && ( + setLookDraftState({ key: lookKey, draft: d })} + /> + )}
diff --git a/src/renderer/src/components/SpritePortrait.tsx b/src/renderer/src/components/SpritePortrait.tsx index a521b3d18..abbfae44d 100644 --- a/src/renderer/src/components/SpritePortrait.tsx +++ b/src/renderer/src/components/SpritePortrait.tsx @@ -1,42 +1,82 @@ +// Ported from Prozzta/guppyos 02d0d50b (MIT). import { useEffect, useRef } from 'react'; import { paintCastPortrait, type OfficeCharacterName } from '@/scene/office/cast'; import { PORTRAIT_W, PORTRAIT_H } from '@/scene/office/portraitArt'; +import { useStore } from '@/store/store'; +import { portraitPlan, type LookDraft, type LookSubject } from '@/scene/themes/looks'; +import { paintPackPortrait } from '@/scene/themes/lookArt'; +import { useThemeLooks } from '@/scene/themes/useThemeLooks'; const FRAME_W = PORTRAIT_W; const FRAME_H = PORTRAIT_H; -export interface SpritePortraitProps { - character: OfficeCharacterName; +/** Who the portrait is of. Under the Office every one of these paints the + * Office character exactly as before; under a theme pack, the pack's art: + * - agent: that agent's look in the active theme (or the pack's fallback); + * - draft: the look builder's live, unsaved look; + * - sample: the pack's declared sample look (onboarding); + * - character alone: always the Office character (the Office cast pickers). */ +export type PortraitSubjectProps = + | { agent: LookSubject & { character: OfficeCharacterName }; draft?: never; sample?: never; character?: never } + | { draft: LookDraft; character: OfficeCharacterName; agent?: never; sample?: never } + | { sample: true; character: OfficeCharacterName; agent?: never; draft?: never } + | { character: OfficeCharacterName; agent?: never; draft?: never; sample?: never }; + +export type SpritePortraitProps = PortraitSubjectProps & { /** Pixels per source pixel. Whole numbers are exact; half-steps (1.5, 2.5) * double every other row, which pixel art survives. The blit runs with * smoothing off, so nothing here is ever interpolated. */ scale?: number; background?: string; -} + /** The theme to draw in (default: the active project theme). */ + themeId?: string; +}; -/** Static standing portrait of an Office cast member (recolored LimeZu sprite). */ -export function SpritePortrait({ - character, - scale = 2, - background = 'transparent' -}: SpritePortraitProps) { +/** Static standing portrait of an agent: the one portrait route for every card. */ +export function SpritePortrait(props: SpritePortraitProps) { + const { scale = 2, background = 'transparent' } = props; + const activeTheme = useStore((s) => s.officeTheme); + const themeId = props.themeId ?? activeTheme; + const looks = useThemeLooks(themeId); const canvasRef = useRef(null); + const character = (props.agent ? props.agent.character : props.character) as OfficeCharacterName; + const subject = props.agent ? { agent: props.agent } : props.draft ? { draft: props.draft, character } : props.sample ? { sample: true as const, character } : { character }; + const plan = portraitPlan(themeId, looks ?? null, subject, { w: FRAME_W, h: FRAME_H }); + // Only what changes the picture: a new look object with the same content does not repaint. + const planKey = plan.kind === 'office' ? `office|${plan.character}` + : [plan.themeId, plan.body.id, plan.row, plan.crop.join(','), plan.colors.skin, plan.colors.shirt, plan.colors.hair].join('|'); + const waiting = looks === undefined && plan.kind === 'office' && (!!props.agent || !!props.draft || !!props.sample); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; - let cancelled = false; ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, canvas.width, canvas.height); if (background !== 'transparent') { ctx.fillStyle = background; ctx.fillRect(0, 0, canvas.width, canvas.height); } - paintCastPortrait(ctx, character, scale).catch(() => { /* asset load race */ }); - return () => { cancelled = true; void cancelled; }; - }, [character, scale, background]); + if (waiting) return; // the pack is still loading: never flash the Office character + if (plan.kind === 'pack' && looks) { paintPackPortrait(ctx, looks, plan); return; } + // The Office paint is async: it goes to its own canvas and lands only while + // this effect is current, so a late one never covers a newer (pack) paint. + let live = true; + const off = document.createElement('canvas'); + off.width = canvas.width; + off.height = canvas.height; + const octx = off.getContext('2d'); + if (!octx) return; + octx.imageSmoothingEnabled = false; + paintCastPortrait(octx, character, scale).then(() => { + if (!live) return; + ctx.clearRect(0, 0, FRAME_W * scale, FRAME_H * scale); // as the Office paint clears its own box + ctx.drawImage(off, 0, 0); + }).catch(() => { /* asset load race */ }); + return () => { live = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [planKey, scale, background, waiting, looks]); // A fractional scale can land on a fractional pixel count; the canvas // attributes are integers either way, so round once and use the same number @@ -50,6 +90,7 @@ export function SpritePortrait({ ref={canvasRef} width={w} height={h} + data-portrait={plan.kind} style={{ width: w, height: h, diff --git a/src/renderer/src/components/ThemeLookBuilder.tsx b/src/renderer/src/components/ThemeLookBuilder.tsx new file mode 100644 index 000000000..b647fa34a --- /dev/null +++ b/src/renderer/src/components/ThemeLookBuilder.tsx @@ -0,0 +1,134 @@ +// Ported from Prozzta/guppyos 02d0d50b (MIT). +import type { CSSProperties } from 'react'; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SpritePortrait } from './SpritePortrait'; +import type { OfficeCharacterName } from '@/scene/office/cast'; +import { LOOK_PARTS, type LookPart } from '@/scene/themes/manifest'; +import { bodiesOf, pickBody, pickPreset, resolveLook, draftToLook, setColour, type LookDraft } from '@/scene/themes/looks'; +import { lookColumn } from '@/scene/themes/lookArt'; +import type { ThemeLooks } from '@/scene/themes/themeCatalog'; + +export interface ThemeLookBuilderProps { + looks: ThemeLooks; + draft: LookDraft; + onChange: (d: LookDraft) => void; + /** A premade was picked; `name` is its label (the caller decides whether to use it). */ + onPreset?: (name: string | undefined) => void; + /** A stable Office-compatible fallback identity for the portrait API. */ + character: OfficeCharacterName; +} + +const lab: CSSProperties = { fontSize: 11, color: 'var(--cth-ink-700)', fontWeight: 600 }; +const chip = (active: boolean): CSSProperties => ({ + display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, padding: 4, border: 'none', cursor: 'pointer', + font: 'inherit', color: 'var(--cth-ink-900)', + background: active ? 'var(--cth-sky-light)' : 'var(--cth-cream-100)', + boxShadow: active ? 'inset 0 0 0 1.5px var(--cth-ink-500)' : 'inset 0 0 0 1px var(--cth-ink-100)', +}); +const chipName: CSSProperties = { fontSize: 11, lineHeight: '14px', whiteSpace: 'nowrap' }; + +/** The front frame of the draft, large: the live preview of the floor sprite. */ +function FramePreview({ looks, draft, scale }: { looks: ThemeLooks; draft: LookDraft; scale: number }) { + const ref = useRef(null); + const { frameWidth: fw, frameHeight: fh, rows } = looks.sprite; + const r = resolveLook(looks.builder, looks.columns, draftToLook(draft), ''); + const key = [r.body.id, r.colors.skin, r.colors.shirt, r.colors.hair].join('|'); + useEffect(() => { + const g = ref.current?.getContext('2d'); + if (!g) return; + g.imageSmoothingEnabled = false; + g.clearRect(0, 0, g.canvas.width, g.canvas.height); + g.drawImage(lookColumn(looks, r.body, r.colors), 0, rows.down * fh, fw, fh, 0, 0, fw * scale, fh * scale); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, looks, scale]); + return ; +} + +/** The theme's character builder (Pam's P3): body, colours, presets, with a live + * preview. It takes the Character row's place on a pack's floor; Add and Edit + * save its draft the same way. Body, swatch and preset names come from the pack + * and stay as written there. */ +export function ThemeLookBuilder({ looks, draft, onChange, onPreset, character }: ThemeLookBuilderProps) { + const { t } = useTranslation(); + const b = looks.builder, cols = looks.columns; + const bodies = bodiesOf(b, cols); + const body = bodies.find((x) => x.id === draft.bodyId) ?? bodies[0]; + const parts = LOOK_PARTS.filter((p) => body.parts?.[p]); + const custom = b?.customColours !== false; + const partLabel: Record = { skin: t('looks.skin'), shirt: t('looks.shirt'), hair: t('looks.hair') }; + + return ( +
+
+
+ {t('looks.title', { theme: looks.label })} +
+
{t('looks.sub', { theme: looks.label })}
+
+
+
+ + +
+
+
{t('looks.body')}
+
+ {bodies.map((x) => ( + + ))} +
+ {parts.length > 0 && ( + <> +
{t('looks.colours')}
+ {parts.map((p) => { + const sw = Object.entries(b?.swatches?.[p] ?? {}); + return ( +
+ {partLabel[p]} + {sw.map(([n, c]) => ( +
+ ); + })} + + )} + {!!b?.presets?.length && ( + <> +
{t('looks.presets')}
+
+ {b.presets.map((p) => { + const picked = pickPreset(b, cols, draft, p.id); + return ( + + ); + })} +
+ + )} +
+
+
+ ); +} diff --git a/src/renderer/src/hooks/useHive.ts b/src/renderer/src/hooks/useHive.ts index cd9797522..d706bbcf3 100644 --- a/src/renderer/src/hooks/useHive.ts +++ b/src/renderer/src/hooks/useHive.ts @@ -387,6 +387,8 @@ export function useHive(config: HarnessConfig | null): void { // Synchronous guard (no await between check and set) → exactly one spawn. if (cancelled || godSpawning.current) return; godSpawning.current = true; + // The orchestrator's looks per theme pack are roster data: keep them across the respawn. + const godLooks = useStore.getState().agents.find((a) => a.id === GOD_ID)?.appearanceByTheme; useStore.getState().removeAgent(GOD_ID); // clear any stale restored entry // A prior rename (Edit Agent panel → renameAgent() → hive.ts's renameAgent()) @@ -436,6 +438,7 @@ export function useHive(config: HarnessConfig | null): void { provider: godProvider, model: godModel, isGod: true, + ...(godLooks ? { appearanceByTheme: godLooks } : {}), recentTextTs: Date.now() }; useStore.getState().addAgent(god); diff --git a/src/renderer/src/i18n/locales/ar.json b/src/renderer/src/i18n/locales/ar.json index d3e2e3a83..2dd107df6 100644 --- a/src/renderer/src/i18n/locales/ar.json +++ b/src/renderer/src/i18n/locales/ar.json @@ -487,7 +487,8 @@ "sections": { "identity": { "label": "الهوية", - "hint": "الاسم · الشخصية · اللون" + "hint": "الاسم · الشخصية · اللون", + "hintPack": "الاسم · الشكل · اللون" }, "workspace": { "label": "مساحة العمل", @@ -1415,5 +1416,16 @@ }, "stationFloor": { "packFailed": "تعذّر تحميل {{name}}، لذا يُعرض The Office بدلًا منه. كل الوكلاء ما زالوا هنا." + }, + "looks": { + "title": "الشكل في {{theme}}", + "sub": "يستخدم {{theme}} وحده هذا الشكل. في الأرضيات الأخرى يحتفظ هذا الوكيل بشخصيته.", + "body": "الجسم", + "colours": "الألوان", + "skin": "البشرة", + "shirt": "القميص", + "hair": "لون الشعر", + "custom": "لون مخصّص", + "presets": "إعدادات جاهزة" } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5b8bb9cff..c0b95cd28 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -487,7 +487,8 @@ "sections": { "identity": { "label": "Identity", - "hint": "name · character · color" + "hint": "name · character · color", + "hintPack": "name · look · color" }, "workspace": { "label": "Workspace", @@ -1415,5 +1416,16 @@ }, "stationFloor": { "packFailed": "{{name}} could not load, so The Office is showing instead. Every agent is still here." + }, + "looks": { + "title": "Look on {{theme}}", + "sub": "Only {{theme}} uses this look. On other floors this agent keeps its character.", + "body": "Body", + "colours": "Colours", + "skin": "Skin", + "shirt": "Shirt", + "hair": "Hair colour", + "custom": "Custom colour", + "presets": "Presets" } } diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index d3aeb8e6c..a104a2d15 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -487,7 +487,8 @@ "sections": { "identity": { "label": "身份", - "hint": "名字 · 角色 · 颜色" + "hint": "名字 · 角色 · 颜色", + "hintPack": "名字 · 外观 · 颜色" }, "workspace": { "label": "工作区", @@ -1415,5 +1416,16 @@ }, "stationFloor": { "packFailed": "{{name}} 无法加载,所以先显示 The Office。所有智能体都还在。" + }, + "looks": { + "title": "{{theme}} 中的外观", + "sub": "只有 {{theme}} 使用这个外观。在其他楼层,这个智能体保留原来的角色。", + "body": "体型", + "colours": "颜色", + "skin": "肤色", + "shirt": "上衣", + "hair": "发色", + "custom": "自定义颜色", + "presets": "预设" } } diff --git a/src/renderer/src/scene/themes/useThemeLooks.ts b/src/renderer/src/scene/themes/useThemeLooks.ts new file mode 100644 index 000000000..4161e4077 --- /dev/null +++ b/src/renderer/src/scene/themes/useThemeLooks.ts @@ -0,0 +1,19 @@ +// Ported from Prozzta/guppyos 02d0d50b (MIT). +import { useEffect, useState } from 'react'; +import { OFFICE_THEME_ID } from './manifest'; +import { loadThemeLooks, type ThemeLooks } from './themeCatalog'; + +/** The theme's looks: null for the office or an unusable pack (Office art is + * shown), undefined while a pack is still loading (nothing is shown yet, so + * a card never flashes the wrong character). */ +export function useThemeLooks(themeId: string): ThemeLooks | null | undefined { + const [state, setState] = useState<{ id: string; looks: ThemeLooks | null } | null>(null); + useEffect(() => { + let live = true; + if (themeId === OFFICE_THEME_ID) return; + loadThemeLooks(themeId).then((l) => { if (live) setState({ id: themeId, looks: l }); }, () => { if (live) setState({ id: themeId, looks: null }); }); + return () => { live = false; }; + }, [themeId]); + if (themeId === OFFICE_THEME_ID) return null; + return state && state.id === themeId ? state.looks : undefined; +} diff --git a/src/renderer/src/store/store.ts b/src/renderer/src/store/store.ts index 9dc4174f2..b34161271 100644 --- a/src/renderer/src/store/store.ts +++ b/src/renderer/src/store/store.ts @@ -46,8 +46,9 @@ export interface Agent { character: OfficeCharacterName; accent: AccentColorName; /** This agent's look in each theme pack, by theme id (the Office uses - * `character` and `accent`, which a look never replaces). Not saved with - * the roster yet; without one an agent shows its pack's default look. */ + * `character` and `accent`, which a look never replaces). Durable: saved + * with the roster, kept when the project switches theme and back. Without + * one an agent shows its pack's default look. */ appearanceByTheme?: LooksByTheme; /** persistent job / hire one-liner — same string as hive registry `role`. * Live status belongs on `status` / `action`, never here. */ From ea8d156cb4acb2d832eb3e7418a170939fa47831 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 11:40:27 +0530 Subject: [PATCH 25/42] feat(agents): the pack look on agent cards, and the orchestrator's first look - AgentCard takes the agent's id and looks. On a pack floor the portrait tile shows the figure the floor draws for that agent, from the same resolver, in the same tile. On the Office, or when the pack cannot load, it keeps the Office character; while the pack loads the tile stays empty. AgentStrip passes both. - useOrchestratorThemeLook: an orchestrator with no look on the active pack gets the pack's seed look once, stored like any other look and editable. Its name is never touched. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/App.tsx | 2 ++ src/renderer/src/components/AgentCard.tsx | 11 +++++++-- src/renderer/src/components/AgentStrip.tsx | 2 ++ .../src/hooks/useOrchestratorThemeLook.ts | 23 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/hooks/useOrchestratorThemeLook.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 98c67f9eb..80e572725 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -32,6 +32,7 @@ import { TaskDetailOverlay } from '@/components/TaskDetailOverlay'; import { IdePanel } from '@/ide/IdePanel'; import { useHoldOptionToTalk } from '@/freeflow/holdOption'; import { floorSceneTheme } from '@/scene/themes/themeCatalog'; +import { useOrchestratorThemeLook } from '@/hooks/useOrchestratorThemeLook'; import brandLogo from '@brand/logo.png?url'; // Injected at build time from package.json (see electron.vite.config.ts). @@ -193,6 +194,7 @@ export function App() { // hook) so Michael doesn't boot against the current home while the user may be // about to switch to a different one. useHive(hiveOpened ? config : null); + useOrchestratorThemeLook(); // Pre-warm a persistent terminal for every live agent so its output is // buffered from spawn. Switching agents then re-attaches an already-rendered diff --git a/src/renderer/src/components/AgentCard.tsx b/src/renderer/src/components/AgentCard.tsx index cd5158c4f..d68bfaaab 100644 --- a/src/renderer/src/components/AgentCard.tsx +++ b/src/renderer/src/components/AgentCard.tsx @@ -9,8 +9,13 @@ import { CostHud } from '@/realtime/CostHud'; import { AccentColorName } from '@/design/tokens'; import { OfficeCharacterName } from '@/scene/office/cast'; import { AgentNameEditor } from './AgentNameEditor'; +import type { LooksByTheme } from '@/scene/themes/looks'; export interface AgentCardProps { + /** Hive agent id: with it the portrait is this agent's look on a theme pack's floor. */ + agentId?: string; + /** The agent's theme looks: with agentId, the portrait is that agent's look in the active theme. */ + appearanceByTheme?: LooksByTheme; name: string; character: OfficeCharacterName; accent: AccentColorName; @@ -55,7 +60,7 @@ const fmtK = (n: number): string => `${Math.round(n / 1000)}k`; * and a slim gauge pinned to the bottom edge. Nothing overlaps anything. */ export function AgentCard({ - name, character, accent, status, ptyId, project, action, progress = 0, + agentId, appearanceByTheme, name, character, accent, status, ptyId, project, action, progress = 0, contextTokens, contextLimit, selected, isGod, onClick, onRename, doingCount = 0, onTaskNoteClick, draggable, note, onEditNote }: AgentCardProps) { @@ -197,7 +202,9 @@ export function AgentCard({ display: 'flex', alignItems: 'flex-start', justifyContent: 'center', overflow: 'hidden', flexShrink: 0 }}> - + {agentId !== undefined + ? + : }
diff --git a/src/renderer/src/components/AgentStrip.tsx b/src/renderer/src/components/AgentStrip.tsx index 2f84a6b10..584190e4c 100644 --- a/src/renderer/src/components/AgentStrip.tsx +++ b/src/renderer/src/components/AgentStrip.tsx @@ -133,6 +133,8 @@ export function AgentStrip({ config }: AgentStripProps) { > s.officeTheme); + const godId = useStore((s) => s.agents.find((a) => a.isGod)?.id); + const hasLook = useStore((s) => !!readLook(s.agents.find((a) => a.isGod)?.appearanceByTheme?.[themeId])); + useEffect(() => { + if (!godId || hasLook) return; + const god = useStore.getState().agents.find((a) => a.id === godId); + const seed = themeProjectDefaults(themeId)?.orchestrator; + const look = god && seed ? orchestratorDefaultLook(themeBuilder(themeId) ?? undefined, god.appearanceByTheme?.[themeId], seed.appearance) : null; + if (god && look) useStore.getState().updateAgent(godId, { appearanceByTheme: { ...(god.appearanceByTheme ?? {}), [themeId]: look } }); + }, [themeId, godId, hasLook]); +} From 1f151819f5efa6552c6f1668a029b683d750cd87 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 11:43:20 +0530 Subject: [PATCH 26/42] fix(codex): hold on agents/ and hooks/, on every key that is not confinement, and on the made worktree Three gaps where a hive Codex start ran project settings unreviewed: - Codex also loads .codex/agents/ (a role file can carry mcp_servers) and .codex/hooks/. Every entry of either counts ('agents', 'hooks'), and an unreadable folder holds. In a linked worktree, hooks/ is read from the main checkout too, like hooks.json. Past 20 entries, one item counts the rest ('more'). - Only sandbox_mode and approval_policy are overridden by the agent's own --sandbox and --ask-for-approval. Every other config.toml key holds and is named ('setting'): shell_environment_policy sets the environment of every command, sandbox_workspace_write and permissions widen access, notify runs a program after every turn. - The first check runs on the cwd before the worktree is made, but the worktree is the base branch's committed tree. The made worktree is checked once more before Codex starts; a hold takes the worktree back and then returns the same hold, keyed by the folder the person started in. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/main/codexProjectLayers.ts | 55 +++++++++++++++++++++++++++------- src/main/index.ts | 44 +++++++++++++++++---------- src/preload/index.ts | 5 ++-- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/src/main/codexProjectLayers.ts b/src/main/codexProjectLayers.ts index ba550e507..6372a056b 100644 --- a/src/main/codexProjectLayers.ts +++ b/src/main/codexProjectLayers.ts @@ -15,6 +15,13 @@ * CODEX_HOME and the person's own ~/.codex (their global config, already loaded) * are not project layers. Whatever cannot be read counts as code that runs. * + * A layer carries code through config.toml, hooks.json, rules/, agents/ (a role + * file can carry mcp_servers) and hooks/; every entry of agents/ and hooks/ counts. + * Only sandbox_mode and approval_policy in config.toml are overridden by the + * agent's own --sandbox and --ask-for-approval; every other key can widen access + * or run code (shell_environment_policy sets the environment of every command, + * notify runs a program after every turn), so it counts and is named. + * * Ported from Prozzta/guppyos 02d0d50b (MIT): the TOML reader and the worktree * walk as written there; the trust and version model left out. */ @@ -240,11 +247,14 @@ const realFs: LayerFs = { }; /** One thing a layer carries. `what` is an id the window words; `detail` is data - * (server or hook names, or the error as given). */ + * (server or hook names, a config key, or the error as given). An entry of agents/ + * or hooks/ is one item per file; past CODEX_FOLDER_LIST_MAX, one more item names + * the folder and counts the rest (`more`). */ export interface CodexLayerItem { file: string; - what: 'mcp-servers' | 'hooks' | 'rules' | 'unreadable' | 'does-not-parse' | 'confinement'; + what: 'mcp-servers' | 'hooks' | 'rules' | 'agents' | 'setting' | 'unreadable' | 'does-not-parse' | 'confinement'; detail?: string; + more?: number; } export interface CodexLayer { @@ -274,7 +284,10 @@ export interface CodexLayerInput { fs?: LayerFs; } -const CONFINE_KEYS = ['sandbox_mode', 'approval_policy', 'sandbox_workspace_write', 'permissions', 'default_permissions', 'shell_environment_policy']; +/** The only config.toml keys the agent's own --sandbox and --ask-for-approval override. */ +const CONFINE_KEYS = ['sandbox_mode', 'approval_policy']; +/** The most entries of agents/ or hooks/ listed one by one. */ +export const CODEX_FOLDER_LIST_MAX = 20; const stripVerbatim = (p: string): string => p.replace(/^\\\\\?\\(?!UNC\\)/, ''); const asciiLower = (s: string): string => s.replace(/[A-Z]/g, (c) => c.toLowerCase()); @@ -366,6 +379,17 @@ export function codexProjectLayers(input: CodexLayerInput): CodexLayerReport { if (r.text === null) return null; try { return parseToml(r.text); } catch (e) { items.push({ file, what: 'does-not-parse', detail: (e as Error).message }); return null; } }; + /** Every entry of a folder Codex loads code from (agents/, hooks/); an unreadable one counts. */ + const entriesOf = (folder: string, what: 'agents' | 'hooks', items: CodexLayerItem[]): void => { + const st = stat(folder); + if (st === 'error') { items.push({ file: folder, what: 'unreadable' }); return; } + if (!st || !st.dir) return; + let list: string[]; + try { list = fs.readdirSync(folder).slice().sort(); } + catch (e) { items.push({ file: folder, what: 'unreadable', detail: (e as Error).message }); return; } + for (const n of list.slice(0, CODEX_FOLDER_LIST_MAX)) items.push({ file: P.join(folder, n), what }); + if (list.length > CODEX_FOLDER_LIST_MAX) items.push({ file: folder, what, more: list.length - CODEX_FOLDER_LIST_MAX }); + }; const layers: CodexLayer[] = []; for (const dir of ancestors.slice().reverse()) { @@ -384,8 +408,13 @@ export function codexProjectLayers(input: CodexLayerInput): CodexLayerReport { if (st === 'error') items.push({ file: dotCodex, what: 'unreadable' }); else { const cfg = configOf(dotCodex, items); - if (cfg && 'mcp_servers' in cfg) items.push({ file: P.join(dotCodex, 'config.toml'), what: 'mcp-servers', detail: names(cfg.mcp_servers) }); - for (const k of CONFINE_KEYS) if (cfg && k in cfg) items.push({ file: P.join(dotCodex, 'config.toml'), what: 'confinement', detail: k }); + const cfgFile = P.join(dotCodex, 'config.toml'); + // hooks is listed by hooksOf below; every other key is named here. + for (const k of Object.keys(cfg ?? {})) { + if (k === 'mcp_servers') items.push({ file: cfgFile, what: 'mcp-servers', detail: names(cfg!.mcp_servers) }); + else if (CONFINE_KEYS.includes(k)) items.push({ file: cfgFile, what: 'confinement', detail: k }); + else if (k !== 'hooks') items.push({ file: cfgFile, what: 'setting', detail: k }); + } const rulesDir = P.join(dotCodex, 'rules'); const rst = stat(rulesDir); if (rst === 'error') items.push({ file: rulesDir, what: 'unreadable' }); @@ -394,14 +423,19 @@ export function codexProjectLayers(input: CodexLayerInput): CodexLayerReport { for (const n of fs.readdirSync(rulesDir)) if (n.endsWith('.rules')) items.push({ file: P.join(rulesDir, n), what: 'rules' }); } catch (e) { items.push({ file: rulesDir, what: 'unreadable', detail: (e as Error).message }); } } + entriesOf(P.join(dotCodex, 'agents'), 'agents', items); // A worktree's own hooks count too: which copy Codex reads differs by version. hooksOf(dotCodex, cfg, items); + entriesOf(P.join(dotCodex, 'hooks'), 'hooks', items); } } if (hooksFrom && !own.some((h) => sameKey(hooksFrom!, h, platform))) { const hst = stat(hooksFrom); if (hst === 'error') items.push({ file: hooksFrom, what: 'unreadable' }); - else if (hst && hst.dir) hooksOf(hooksFrom, configOf(hooksFrom, items), items); + else if (hst && hst.dir) { + hooksOf(hooksFrom, configOf(hooksFrom, items), items); + entriesOf(P.join(hooksFrom, 'hooks'), 'hooks', items); + } } if (items.length) layers.push({ dotCodex, ...(hooksFrom ? { hooksFrom } : {}), items }); } @@ -431,10 +465,11 @@ export interface CodexHold { unknown: string[]; } -/** Hold when a layer carries anything but confinement keys (code, command policy, a - * file that cannot be read or does not parse), or something could not be checked, - * unless the folder is allowed. Confinement keys alone start (the agent's own - * --sandbox and --ask-for-approval win). */ +/** Hold when a layer carries anything but sandbox_mode and approval_policy (code, any + * other config key, an entry of agents/ or hooks/, a file that cannot be read or does + * not parse), or something could not be checked, unless the folder is allowed. A + * config.toml with those two keys alone starts (the agent's own --sandbox and + * --ask-for-approval win). */ export function codexHold(report: CodexLayerReport, oks: readonly string[] | undefined, platform: NodeJS.Platform = process.platform): CodexHold | null { const layers = report.layers .map((l) => ({ ...l, items: l.items.filter((it) => it.what !== 'confinement') })) diff --git a/src/main/index.ts b/src/main/index.ts index 93d49402a..c61115756 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2725,17 +2725,23 @@ function pushCodexHolds(): void { try { w.webContents.send('codexHold:push', codexHolds.slice()); } catch { /* window tearing down */ } } } -/** Null to start; else the hold, kept for the card and logged. A check that - * throws holds too (fail safe). */ -function codexStartHold(agentId: string, cwd: string): CodexHoldNotice | null { - let hold: CodexHold | null; +/** Null to start a hive Codex agent in `cwd`; else why it is held. `keyCwd` is the + * folder the person started in when `cwd` is the worktree made from it: the OK and + * the card stay keyed by that folder (spelled as in its main checkout). A check + * that throws holds too (fail safe). */ +function codexStartCheck(agentId: string, cwd: string, keyCwd: string = cwd): CodexHold | null { try { const codexHome = hive.codexHomeOf(agentId) ?? join(hive.root() ?? '', 'agents', agentId, '.codex'); - hold = codexHold(codexProjectLayers({ cwd, codexHome }), readConfig().codexLayerOptIns); + const report = codexProjectLayers({ cwd, codexHome }); + if (keyCwd !== cwd) report.projectFolder = codexProjectLayers({ cwd: keyCwd, codexHome }).projectFolder; + return codexHold(report, readConfig().codexLayerOptIns); } catch (e) { - hold = { folderKey: codexFolderKey(cwd), folder: cwd, layers: [], unknown: [`the check failed (${e instanceof Error ? e.message : String(e)})`] }; + return { folderKey: codexFolderKey(keyCwd), folder: keyCwd, layers: [], unknown: [`the check failed (${e instanceof Error ? e.message : String(e)})`] }; } - if (!hold) return null; +} +/** A held start: kept for the card (one per agent and folder, newest last), pushed, + * logged, and returned as spawnAgentCore's result. */ +function codexStartHeld(agentId: string, hold: CodexHold): { ok: false; error: string; codexHold: CodexHoldNotice } { const notice: CodexHoldNotice = { ...hold, agentId, at: Date.now() }; const same = codexHolds.findIndex((n) => n.agentId === agentId && n.folderKey === hold!.folderKey); if (same >= 0) codexHolds.splice(same, 1); @@ -2743,7 +2749,7 @@ function codexStartHold(agentId: string, cwd: string): CodexHoldNotice | null { while (codexHolds.length > 20) codexHolds.shift(); pushCodexHolds(); try { hive.appendLog({ kind: 'codex-hold', agent: agentId, folder: hold.folder, files: hold.layers.flatMap((l) => l.items.map((it) => it.file)), unknown: hold.unknown }); } catch { /* best-effort */ } - return notice; + return { ok: false, error: `Codex start held: ${hold.folder} has project .codex settings that would run unreviewed. Allow the folder to start it.`, codexHold: notice }; } ipcMain.handle('codexHold:list', () => codexHolds.slice()); /** The card's Allow: records the held folder's key (one OK covers its worktrees); @@ -2869,13 +2875,12 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten // R3: every hive Codex start (first, resume, respawn) passes // --dangerously-bypass-hook-trust, so a project `.codex` that carries code would // run unreviewed. Held until the person allows the folder. Checked on the cwd as - // given, before any worktree is made: a held start leaves nothing behind, and a - // worktree's folder maps to its main checkout anyway. - if (opts.hive && hive.enabled() && provider === 'codex') { - const held = codexStartHold(opts.hive.id, opts.cwd); - if (held) { - return { ok: false, error: `Codex start held: ${held.folder} has project .codex settings that would run unreviewed. Allow the folder to start it.`, codexHold: held }; - } + // given, before any worktree is made, so the usual held start leaves nothing + // behind; a made worktree is checked once more below. + const codexHive = !!(opts.hive && hive.enabled() && provider === 'codex'); + if (codexHive) { + const held = codexStartCheck(opts.hive!.id, opts.cwd); + if (held) return codexStartHeld(opts.hive!.id, held); } // Git isolation: when requested and the cwd is a real repo, give this agent // its own worktree on an `agent/` branch so it can't clobber other agents' @@ -2902,6 +2907,15 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten const br = await getBranch(origCwd); const baseBranch = 'current' in br && br.current ? br.current : 'main'; const wt = await addWorktree(origCwd, wtPath, baseBranch); + // R3, second check: the worktree holds the base branch's committed `.codex`, not + // the working tree checked above. Held: the worktree is taken back (as teardown + // does) before the card goes up, so an Allow starts again on a free path. + const held = wt.ok && codexHive ? codexStartCheck(opts.hive!.id, wtPath, origCwd) : null; + if (held) { + const r = await removeWorktree(origCwd, wtPath).catch((e: unknown) => ({ ok: false, error: String(e), refused: false })); + if (!r.ok) { console.error('[worktree] removeWorktree failed:', r.error); if (r.refused) noteRemovalRefused(wtPath, r.error); } + return codexStartHeld(opts.hive!.id, held); + } if (wt.ok) { opts.cwd = wtPath; worktreePaths.set(opts.id, wtPath); diff --git a/src/preload/index.ts b/src/preload/index.ts index f5b245e58..397509004 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -142,7 +142,8 @@ export interface AgentDirectoryEntry { } /** R3: a held hive Codex start (main's CodexHoldNotice). `what` is an id the window - * words; `detail` is data (names, or an error as given). */ + * words; `detail` is data (names, a config key, or an error as given); `more` counts + * the entries of agents/ or hooks/ past the first 20, on an item naming the folder. */ export interface CodexHoldView { agentId: string; at: number; @@ -152,7 +153,7 @@ export interface CodexHoldView { layers: Array<{ dotCodex: string; hooksFrom?: string; - items: Array<{ file: string; what: 'mcp-servers' | 'hooks' | 'rules' | 'unreadable' | 'does-not-parse' | 'confinement'; detail?: string }>; + items: Array<{ file: string; what: 'mcp-servers' | 'hooks' | 'rules' | 'agents' | 'setting' | 'unreadable' | 'does-not-parse' | 'confinement'; detail?: string; more?: number }>; }>; /** What could not be checked (each counts as code that runs). */ unknown: string[]; From 1a7010b6cdc1427f30e46e1a65cdbd6d4ab26a0a Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 11:47:26 +0530 Subject: [PATCH 27/42] feat(theme): main registers the bundled theme packs by the window's own validator main/packThemes.ts reads every themes//theme.json into the main bundle and registers the packs as project themes before any config is read (the first import of index.ts). main/packThemeEntries.ts keeps only manifests that pass the window's validateManifest, folders in name order, the first pack of an id winning, so main accepts a pack id exactly when the window does. The orchestrator seed is the validated manifest's; Star Hike's has no name, so it is a look only. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/main/index.ts | 1 + src/main/packThemeEntries.ts | 23 +++++++++++++++++++++++ src/main/packThemes.ts | 15 +++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/main/packThemeEntries.ts create mode 100644 src/main/packThemes.ts diff --git a/src/main/index.ts b/src/main/index.ts index c61115756..1b9eefa25 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import './packThemes'; // theme packs join the project-theme catalogue before any config is read import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, powerMonitor, powerSaveBlocker, screen, shell, Notification } from 'electron'; import { spawn } from 'node:child_process'; import { diff --git a/src/main/packThemeEntries.ts b/src/main/packThemeEntries.ts new file mode 100644 index 000000000..5481f969d --- /dev/null +++ b/src/main/packThemeEntries.ts @@ -0,0 +1,23 @@ +/** + * The theme packs main registers, by the window's own rule: each manifest must + * pass validateManifest (the renderer's validator, pure and DOM free), folders + * are taken in name order, and the first pack of an id wins. The orchestrator + * seed is the validated manifest's. Pure; main/packThemes.ts feeds it the + * bundled manifests. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ +import { validateManifest } from '../renderer/src/scene/themes/manifest'; +import type { PackThemeEntry } from '../shared/projectTheme'; + +/** The pack entries among parsed theme.json manifests keyed by path (".../themes//theme.json"). */ +export function validatedPackThemeEntries(manifests: Record): PackThemeEntry[] { + const out: PackThemeEntry[] = []; + const byDir = Object.entries(manifests).map(([file, raw]) => ({ dir: file.replace(/\\/g, '/').split('/').slice(-2, -1)[0] ?? '', raw })); + for (const { dir, raw } of byDir.sort((a, b) => a.dir.localeCompare(b.dir))) { + const m = validateManifest(raw, dir).manifest; + if (!m || out.some((e) => e.id === m.id)) continue; + out.push({ id: m.id, label: m.label, orchestrator: m.projectDefaults?.orchestrator }); + } + return out; +} diff --git a/src/main/packThemes.ts b/src/main/packThemes.ts new file mode 100644 index 000000000..f7ce0de21 --- /dev/null +++ b/src/main/packThemes.ts @@ -0,0 +1,15 @@ +/// +/** + * The bundled theme packs (themes//theme.json) as project themes, so main + * accepts a pack id wherever it accepts a built-in one (config:setProjectTheme, + * voice) exactly when the window would. Imported first by index.ts, so + * registration runs before any config is read. + * + * Ported from Prozzta/guppyos 02d0d50b (MIT). + */ +import { registerPackThemes } from '../shared/projectTheme'; +import { validatedPackThemeEntries } from './packThemeEntries'; + +const MANIFESTS = import.meta.glob('../../themes/*/theme.json', { eager: true, import: 'default' }) as Record; + +registerPackThemes(validatedPackThemeEntries(MANIFESTS)); From 1ceafce70e0890907ca7fdb90894d5433d520c48 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 11:50:02 +0530 Subject: [PATCH 28/42] fix(codex): a held start's take back deletes the branch made for it when nothing is on it After the second check holds, the worktree is taken back but its agent/ branch stayed, so the start after an Allow could not make its worktree again and ran in the shared folder. The worktree's branch is read before the take back; after removeWorktree succeeds it is deleted only when it is the app's name for that path, is not the base, and git rev-list --count .. gives 0 (counted against the base, not HEAD, which a detached main checkout does not point at). A count git cannot give, or anything ahead, keeps it. Teardown and respawn are as before. Co-Authored-By: Claude Opus 5.5 --- src/main/git.ts | 17 +++++++++++++++++ src/main/index.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/git.ts b/src/main/git.ts index 0e5cb2ff1..e4b9be07d 100644 --- a/src/main/git.ts +++ b/src/main/git.ts @@ -274,6 +274,23 @@ export async function addWorktree( return { ok: false, error: fallback.error }; } +/** After a held start's worktree is taken back: delete the branch the app made for + * it, so the next start gets its own worktree again. `branch` is the worktree's + * branch, read before the take back. Deleted only when it is the app's name for + * that path, is not the base, and has nothing ahead of the base, counted against + * the base and not HEAD (a detached main checkout's HEAD is not the base). A count + * git cannot give keeps the branch. */ +export async function dropHeldAgentBranch( + cwd: string, wtPath: string, branch: string | null, baseBranch: string +): Promise<{ deleted: boolean; error?: string }> { + if (!branch || branch !== agentBranchFor(wtPath) || branch === baseBranch) return { deleted: false }; + const ahead = await runGit(cwd, ['rev-list', '--count', `${baseBranch}..${branch}`, '--']); + if (!ahead.ok) return { deleted: false, error: ahead.error }; + if (ahead.stdout.trim() !== '0') return { deleted: false }; + const del = await runGit(cwd, ['branch', '-D', branch]); + return del.ok ? { deleted: true } : { deleted: false, error: del.error }; +} + /** The two removals unlinkLinksUnder uses, and how it reads (injectable for tests). */ export interface LinkOps { unlink: (p: string) => Promise; diff --git a/src/main/index.ts b/src/main/index.ts index 1b9eefa25..6b9798daa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,7 +31,7 @@ import type { AnswerOutcome } from '../shared/humanQuestion'; import { normalizeWeekly, weeklyDelayMs } from '../shared/weeklySchedule'; import { getBranch, getStatus, getLog, getBranches, getAheadBehind, isRepo, getDiff, mainRepoRoot, - addWorktree, removeWorktree, worktreeHasUnintegratedWork, worktreeIsGcSafe, + addWorktree, removeWorktree, dropHeldAgentBranch, worktreeHasUnintegratedWork, worktreeIsGcSafe, getLogGraph, getCommitFiles, getFileAtRev, compareRefs, listWorktrees, checkoutRef } from './git'; import { linkWorktreeDeps, unlinkWorktreeDeps } from './worktreeDeps'; @@ -2910,11 +2910,17 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten const wt = await addWorktree(origCwd, wtPath, baseBranch); // R3, second check: the worktree holds the base branch's committed `.codex`, not // the working tree checked above. Held: the worktree is taken back (as teardown - // does) before the card goes up, so an Allow starts again on a free path. + // does) and the branch made for it deleted when nothing is on it, before the + // card goes up, so an Allow starts again in its own worktree. const held = wt.ok && codexHive ? codexStartCheck(opts.hive!.id, wtPath, origCwd) : null; if (held) { + const made = await getBranch(wtPath); const r = await removeWorktree(origCwd, wtPath).catch((e: unknown) => ({ ok: false, error: String(e), refused: false })); if (!r.ok) { console.error('[worktree] removeWorktree failed:', r.error); if (r.refused) noteRemovalRefused(wtPath, r.error); } + else { + const drop = await dropHeldAgentBranch(origCwd, wtPath, 'current' in made ? made.current : null, baseBranch); + if (drop.error) console.error('[worktree] held start: branch kept:', drop.error); + } return codexStartHeld(opts.hive!.id, held); } if (wt.ok) { From b7ea79dc819e058f8de7aa76eff14da1d5fd65ab Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 11:59:51 +0530 Subject: [PATCH 29/42] feat(theme): a new project's home change carries the floor chosen for it P2: the window picks a new project's floor before "create project", but a fresh home change relaunches the app, so it cannot save the theme after. changeHome(newHome, 'fresh', projectTheme) now takes it: the theme must be a known id ('invalid-theme' otherwise) and the folder missing or holding nothing but .DS_Store ('not-empty', or 'unreadable' when it cannot be read as a folder); then the new home's entry in officeThemeByHive is that theme. A move ignores it and keeps its own. The pack's orchestrator seed stays with the window. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/main/fs.ts | 11 +++++++++++ src/main/index.ts | 22 +++++++++++++++++----- src/preload/index.ts | 9 ++++++--- src/shared/projectTheme.ts | 9 ++++++--- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/main/fs.ts b/src/main/fs.ts index e79198e20..0b763a9ac 100644 --- a/src/main/fs.ts +++ b/src/main/fs.ts @@ -364,3 +364,14 @@ export async function statAbs(p: string): Promise<{ exists: boolean; isFile: boo return { exists: false, isFile: false, path: abs }; } } + +/** P2: whether a new project may start in `dir`. Missing, or holding nothing but + * .DS_Store, is 'empty'; anything else in it is 'not-empty'; a path that cannot be + * read as a folder (a file, no permission) is 'unreadable'. */ +export async function newProjectFolderState(dir: string): Promise<'empty' | 'not-empty' | 'unreadable'> { + try { + return (await readdir(dir)).some((n) => n !== '.DS_Store') ? 'not-empty' : 'empty'; + } catch (e) { + return (e as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'empty' : 'unreadable'; + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 6b9798daa..0bf69479a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,13 +18,13 @@ import { readConfig, writeConfig, setAgentTokenCap, setGraphicsMode, setProjectThemeForHome, setCodexFolderOk, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted, modelForRole, OPS_STANDUP_MISSION, HEARTBEAT_MISSION, COMPACT_MAINTENANCE_MISSION, type HarnessConfig, type ScheduledMission } from './config'; -import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde } from './fs'; +import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde, newProjectFolderState } from './fs'; import { integrityIssues, integrityPath } from './damagedFile'; import { applyGraphicsModeAtStartup, appliedGraphicsMode, graphicsStatus, logGraphicsWhenKnown, watchGpuReports } from './graphics'; import type { GraphicsStatusView } from '../shared/graphicsMode'; import { HistoryService } from './historyService'; import { parseAnswerRequest } from './askMeAnswers'; -import { moveProjectTheme, resolveProjectTheme, type ProjectThemeSave } from '../shared/projectTheme'; +import { isProjectThemeId, moveProjectTheme, resolveProjectTheme, type ProjectThemeId, type ProjectThemeSave } from '../shared/projectTheme'; import { ReloadPolicy, type RecoveryNotice } from '../shared/viewRecovery'; import { codexFolderKey, codexHold, codexProjectLayers, type CodexHold, type CodexHoldNotice } from './codexProjectLayers'; import type { AnswerOutcome } from '../shared/humanQuestion'; @@ -3430,7 +3430,7 @@ ipcMain.handle('config:ensureHome', (_evt, path: unknown) => { // re-binds against the new root. mode: 'move' copies the data (old kept as a // safety net), 'fresh' just re-points and bootstraps an empty home. ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { - const p = (payload ?? {}) as { newHome?: unknown; mode?: unknown }; + const p = (payload ?? {}) as { newHome?: unknown; mode?: unknown; projectTheme?: unknown }; if (typeof p.newHome !== 'string' || !p.newHome) return { ok: false, error: 'invalid newHome' }; const mode: 'move' | 'fresh' = p.mode === 'fresh' ? 'fresh' : 'move'; // expandTilde BEFORE resolve: both UI callers feed a folder-dialog result @@ -3443,6 +3443,17 @@ ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { const oldHome = oldRaw ? resolve(oldRaw) : null; // Read before anything is copied in: a fresh home with no hive is a new project. const blankProject = !existsSync(join(newHome, 'hive')); + // P2: a fresh project may come with the floor the person chose (a move keeps its + // own). It only starts a new project in an empty folder (nothing but .DS_Store), so + // it never lands on an existing project. Errors are ids the window words. + const chosen = mode === 'fresh' && p.projectTheme != null ? p.projectTheme : undefined; + let theme: ProjectThemeId | undefined; + if (chosen !== undefined) { + if (!isProjectThemeId(chosen)) return { ok: false, error: 'invalid-theme' }; + theme = chosen; + const folder = await newProjectFolderState(newHome); + if (folder !== 'empty') return { ok: false, error: folder }; + } // Guard against same-folder / nested-folder (a move would self-copy forever). if (oldHome) { @@ -3499,8 +3510,9 @@ ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { // Repoint config and relaunch so every service re-bootstraps against newHome. // (Identical recovery path to resetAll — relaunch is the clean re-bind.) allowQuit = true; - // The project's floor theme goes with it on a move (shared/projectTheme). - const officeThemeByHive = moveProjectTheme(readConfig().officeThemeByHive, { mode, oldHome, newHome, blankProject }); + // The project's floor theme goes with it on a move; a new project gets the one + // chosen for it (shared/projectTheme). + const officeThemeByHive = moveProjectTheme(readConfig().officeThemeByHive, { mode, oldHome, newHome, blankProject, theme }); writeConfig({ harnessHome: newHome, officeThemeByHive }); try { ptyManager.killAll(); } catch (e) { console.error('[changeHome] killAll:', e); } app.relaunch(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 397509004..077b32afd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -709,9 +709,12 @@ const api = { /** Change the harness home folder. 'move' copies the existing hive + palace * into the new folder (old kept as a safety net); 'fresh' just re-points and * bootstraps an empty home. On success the app relaunches (never resolves); - * on failure (e.g. copy error) returns { ok: false, error }. */ - changeHome: (newHome: string, mode: 'move' | 'fresh'): Promise<{ ok: boolean; error?: string }> => - ipcRenderer.invoke('config:changeHome', { newHome, mode }), + * on failure (e.g. copy error) returns { ok: false, error }. `projectTheme` + * (fresh only, P2): the new project's floor. The folder must then be missing or + * empty (nothing but .DS_Store); else error is an id: 'not-empty', 'unreadable' + * (the folder could not be read) or 'invalid-theme'. */ + changeHome: (newHome: string, mode: 'move' | 'fresh', projectTheme?: ProjectThemeId): Promise<{ ok: boolean; error?: string }> => + ipcRenderer.invoke('config:changeHome', { newHome, mode, ...(projectTheme !== undefined ? { projectTheme } : {}) }), // ─── Filesystem (sandboxed to cwd) ─────────────────────────────────────── listDir: (root: string, rel: string): Promise< diff --git a/src/shared/projectTheme.ts b/src/shared/projectTheme.ts index b4c576fca..07d6523a0 100644 --- a/src/shared/projectTheme.ts +++ b/src/shared/projectTheme.ts @@ -139,10 +139,11 @@ export function isProjectThemeSeed(value: unknown): value is ProjectThemeSeed { /** The map after a home change. A move takes the project's entry to its new home; a fresh * home that held no project drops any entry left there by an earlier project at that path, - * so it shows the global theme like any new project. */ + * so it shows the global theme like any new project, or the floor the person chose for it + * (`theme`, P2; ignored on a move). */ export function moveProjectTheme( map: Record | undefined, - change: { mode: 'move' | 'fresh'; oldHome?: string | null; newHome: string; blankProject: boolean }, + change: { mode: 'move' | 'fresh'; oldHome?: string | null; newHome: string; blankProject: boolean; theme?: ProjectThemeId }, ): Record { const next = { ...(map ?? {}) }; const newKey = projectThemeHomeKey(change.newHome); @@ -154,6 +155,8 @@ export function moveProjectTheme( if (theme) next[newKey] = theme; return next; } - if (change.blankProject) for (const k of Object.keys(next)) if (projectThemeHomeKey(k) === newKey) delete next[k]; + if (!change.blankProject) return next; + for (const k of Object.keys(next)) if (projectThemeHomeKey(k) === newKey) delete next[k]; + if (isProjectThemeId(change.theme)) next[newKey] = change.theme; return next; } From 583f2e2ac1dbeda4b20b3f4a42ef0588509a03c3 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 12:07:59 +0530 Subject: [PATCH 30/42] feat(theme): each project picks its own floor in Settings, and a switch keeps every agent - App start sets the floor from this project's theme: its own entry, else the global one (resolveProjectTheme), then floorSceneTheme from shared/projectTheme. The window's own copy of that rule is gone. - The window registers the valid bundled packs as project themes: the catalogue's first load does it, before first render. - Office Theme in Settings: a line names the project; The Office and the packs are always listed, the TV-show floors while their switch is on. A pick waits for Save, with a "switches on Save" chip and one line saying what Save does. The delete dialog and its kill and archive flow are gone: every agent stays, only the look changes. - Save stores the pick for this project through setProjectTheme and shows the theme main reads back. When it could not be stored (the settings file damaged or busy), the old floor stays and one line says so. - New keys officeTheme.onSave, officeTheme.forProject, officeTheme.keepsAgents and officeTheme.notSaved in en, ar and zh-CN. The twelve keys only the delete dialog used are removed. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/App.tsx | 9 +- .../src/components/OfficeThemePicker.tsx | 267 +++++------------- src/renderer/src/components/SettingsModal.tsx | 35 ++- src/renderer/src/i18n/locales/ar.json | 16 +- src/renderer/src/i18n/locales/en.json | 16 +- src/renderer/src/i18n/locales/zh-CN.json | 16 +- src/renderer/src/main.tsx | 5 + src/renderer/src/scene/themes/themeCatalog.ts | 8 +- src/renderer/src/store/config.ts | 5 +- 9 files changed, 130 insertions(+), 247 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 80e572725..483d3638d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,7 +31,7 @@ import { FullscreenTerminal } from '@/components/FullscreenTerminal'; import { TaskDetailOverlay } from '@/components/TaskDetailOverlay'; import { IdePanel } from '@/ide/IdePanel'; import { useHoldOptionToTalk } from '@/freeflow/holdOption'; -import { floorSceneTheme } from '@/scene/themes/themeCatalog'; +import { floorSceneTheme, resolveProjectTheme } from '@shared/projectTheme'; import { useOrchestratorThemeLook } from '@/hooks/useOrchestratorThemeLook'; import brandLogo from '@brand/logo.png?url'; @@ -110,9 +110,10 @@ export function App() { // show the voice button disabled-with-tooltip when Free Flow is on but no // Groq key is set (Settings keeps this in sync on save). useStore.getState().setHasGroqKey(!!c.groqApiKey); - // Mirror the active office theme so OfficeFloor renders it (a TV-show map - // only while the tvShowOffices flag is on; a pack always). Settings keeps this synced. - useStore.getState().setOfficeTheme(floorSceneTheme(c.officeTheme ?? 'office', c.tvShowOffices)); + // Mirror this project's floor theme so OfficeFloor renders it: the project's own + // entry, else the global theme (a TV-show map only while the tvShowOffices flag + // is on; a pack always). Settings keeps this synced. + useStore.getState().setOfficeTheme(floorSceneTheme(resolveProjectTheme(c, c.harnessHome), c.tvShowOffices)); // Mirror the triggers so Settings → Connections and the Command Center's // Triggers tab read one list, not two copies that drift — whichever surface // saves calls these same setters and the other repaints. No extra IPC: main diff --git a/src/renderer/src/components/OfficeThemePicker.tsx b/src/renderer/src/components/OfficeThemePicker.tsx index ae411576f..5f6f6d076 100644 --- a/src/renderer/src/components/OfficeThemePicker.tsx +++ b/src/renderer/src/components/OfficeThemePicker.tsx @@ -2,106 +2,77 @@ import { useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import type { HarnessConfig } from '@/store/config'; import { useStore } from '@/store/store'; -import { disposeTerminal } from './terminalPool'; -import { PixelPanel } from './PixelPanel'; import { PixelButton } from './PixelButton'; -import { Icon } from './Icon'; -import { floorSceneTheme, listThemes } from '@/scene/themes/themeCatalog'; - -type PickId = NonNullable; +import { listThemes } from '@/scene/themes/themeCatalog'; +import { floorSceneTheme, isPackProjectTheme, offeredProjectThemes, type ProjectThemeId } from '@shared/projectTheme'; + +// The TV-show maps with no content yet show a "soon" chip; their floor is the +// office until the content lands. A pack's card line and swatch: the catalogue +// gives a pack only its label. +const NOT_BUILT = new Set(['friends', 'siliconvalley', 'got', 'hogwarts']); +const PACK_CARD: Record = { + starhike: { blurb: 'The starship bridge', swatch: '#1f6b66' }, +}; + +/** The project's folder name, for "The floor for {{project}}". */ +function projectName(home: string | null | undefined): string { + const parts = (home ?? '').replace(/[\\/]+$/, '').split(/[\\/]/); + return parts[parts.length - 1] || (home ?? ''); +} -// TV-show office themes (Phase 1 = the switch flow infra). Only `office` has a -// real map+cast today; the five shows render via the loader's office fallback -// until their content lands (Phase 2). `built: false` shows a "soon" tag and a -// fallback note on switch, but the destructive switch flow still runs so the -// whole pipeline (modal → delete cast → persist → re-seat) is exercisable now. -// Star Hike is a bundled theme pack (themes/starhike): like the office it is -// listed whether or not the TV-show flag is on, with its art credit. -interface ThemeMeta { id: PickId; label: string; blurb: string; built: boolean; swatch: string; always?: boolean; pack?: boolean; } -const THEME_META: ThemeMeta[] = [ - { id: 'office', label: 'The Office', blurb: 'Dunder Mifflin — the original floor', built: true, swatch: '#6b5a4a', always: true }, - { id: 'starhike', label: 'Star Hike', blurb: 'The starship bridge', built: true, swatch: '#1f6b66', always: true, pack: true }, - { id: 'friends', label: 'Friends', blurb: 'Central Perk coffee house', built: false, swatch: '#9a5a32' }, - { id: 'brooklyn99', label: 'Brooklyn Nine-Nine', blurb: 'The 99th precinct bullpen', built: true, swatch: '#3a5a7a' }, - { id: 'siliconvalley', label: 'Silicon Valley', blurb: 'The Hacker Hostel', built: false, swatch: '#4a6a4a' }, - { id: 'got', label: 'Game of Thrones', blurb: 'The Red Keep throne room', built: false, swatch: '#6a2630' }, - { id: 'hogwarts', label: 'Harry Potter', blurb: 'Hogwarts great hall', built: false, swatch: '#39305a' }, -]; +export interface OfficeThemePickerProps { + config: HarnessConfig; + /** The project's theme in force: main's read back after the last Save. */ + current: ProjectThemeId; + /** The card picked and waiting for Settings' Save, or null. */ + pick: ProjectThemeId | null; + onPick: (id: ProjectThemeId | null) => void; + /** The last Save could not store the pick (config.json damaged or busy). */ + notSaved: boolean; +} -/** Settings "Office Theme" section: the theme cards (The Office and Star Hike - * always; the five TV-show floors while the experimental flag is on), the flag - * toggle under them, and the destructive switch flow (report §E). - * Self-contained so it stays out of SettingsModal's bulk. */ -export function OfficeThemePicker({ config }: { config: HarnessConfig }) { +/** Settings "Office Theme" section: this project's floor. The Office and the + * packs are always listed, the five TV-show floors while the experimental flag + * is on. A pick waits for Save like every other setting here, and a switch + * keeps every agent: only the look changes. */ +export function OfficeThemePicker({ config, current, pick, onPick, notSaved }: OfficeThemePickerProps) { const { t } = useTranslation(); const [enabled, setEnabled] = useState(!!config.tvShowOffices); - const [current, setCurrent] = useState(config.officeTheme ?? 'office'); - const [pending, setPending] = useState(null); - const [busy, setBusy] = useState(false); - const [note, setNote] = useState(''); - - const archiveAgent = useStore((s) => s.archiveAgent); const setOfficeTheme = useStore((s) => s.setOfficeTheme); - // A pack's card shows only while the pack is usable; its credit comes from its theme.json. - const packs = new Map(listThemes().filter((x) => !x.builtIn).map((x) => [x.id, x])); - const cards = THEME_META.filter((x) => (x.always || enabled) && (!x.pack || packs.has(x.id))); - // The floor actually drawn: a hidden TV-show theme shows the office. + const credits = new Map(listThemes().filter((x) => !x.builtIn).map((x) => [x.id, x.credit])); + // The Office first, then the packs, then the TV-show floors. + const offered = offeredProjectThemes(enabled); + const cards = [ + ...offered.filter((x) => x.id === 'office'), + ...offered.filter((x) => isPackProjectTheme(x.id)), + ...offered.filter((x) => x.id !== 'office' && !isPackProjectTheme(x.id)), + ]; + // The floor actually drawn: a TV-show theme with the flag off shows the office. const shown = floorSceneTheme(current, enabled); + const pickLabel = pick ? cards.find((x) => x.id === pick)?.label : undefined; const toggleFlag = async () => { const next = !enabled; setEnabled(next); - setNote(''); + // A TV-show floor picked but not saved leaves the list with the flag. + if (!next && pick && pick !== 'office' && !isPackProjectTheme(pick)) onPick(null); try { await window.cth.updateConfig({ tvShowOffices: next }); - // Flag off → a TV-show theme shows the office; flag on → restore the - // persisted theme. A pack shows either way. + // Flag off: a TV-show theme shows the office; flag on: the project's theme again. setOfficeTheme(floorSceneTheme(current, next)); } catch { setEnabled(!next); // revert optimistic toggle on failure } }; - const nonGodAgents = () => - useStore.getState().agents.filter((a) => !a.isGod && !a.isAssistant); + // Picking the floor already shown takes back a waiting pick. + const onSelect = (id: ProjectThemeId) => onPick(id === shown ? null : id); - const onSelect = (id: PickId) => { - setNote(''); - if (busy || id === shown) return; // no-op on the floor already shown - if (nonGodAgents().length === 0) { void applyTheme(id); return; } // god-only → instant - setPending(id); // workers exist → confirm modal - }; - - const applyTheme = async (id: PickId) => { - setBusy(true); - try { - // Tear down every non-god agent through the EXISTING lifecycle (kill PTY → - // dispose terminal → archive). god + the prep assistant carry over; god's - // PTY is never touched. If a PTY won't die, abort the switch (surface the - // error, don't persist the new theme) rather than leave a half-switched floor. - const victims = nonGodAgents(); - for (const a of victims) { - if (a.ptyId) { - await window.cth.killPty(a.ptyId); - disposeTerminal(a.ptyId); - } - } - for (const a of victims) archiveAgent(a.id); - await window.cth.updateConfig({ officeTheme: id }); - setCurrent(id); - setOfficeTheme(floorSceneTheme(id, enabled)); // → OfficeFloor rebuilds the scene on the new map/cast - const meta = THEME_META.find((t) => t.id === id); - if (meta && !meta.built) setNote(t('officeTheme.notBuiltYet', { label: meta.label })); - } catch (e) { - setNote(t('officeTheme.switchAborted', { error: e instanceof Error ? e.message : String(e) })); - } finally { - setBusy(false); - setPending(null); - } - }; - - const pendingMeta = pending ? THEME_META.find((t) => t.id === pending) : null; + const chip = (color: string): CSSProperties => ({ + fontFamily: 'var(--cth-font-display)', fontSize: 7, color, textTransform: 'uppercase', whiteSpace: 'nowrap', + }); + const noteStyle: CSSProperties = { marginTop: 10, fontSize: 12, lineHeight: '16px', color: 'var(--cth-ink-700)' }; return (
@@ -111,24 +82,29 @@ export function OfficeThemePicker({ config }: { config: HarnessConfig }) { }}> {t('officeTheme.title')}
+
+ {t('officeTheme.forProject', { project: projectName(config.harnessHome) })} +
- {/* Theme cards: The Office and Star Hike always, the TV-show floors while the flag is on */} -
+
{cards.map((theme) => { const isCurrent = theme.id === shown; - const credit = packs.get(theme.id)?.credit; + const isPick = theme.id === pick; + const built = !NOT_BUILT.has(theme.id); + const blurb = PACK_CARD[theme.id]?.blurb ?? theme.blurb; + const swatchColour = PACK_CARD[theme.id]?.swatch ?? theme.swatch; + const credit = credits.get(theme.id); const card: CSSProperties = { display: 'flex', alignItems: 'center', gap: 8, textAlign: 'start', - padding: 8, cursor: busy ? 'default' : 'pointer', minWidth: 0, width: '100%', - background: isCurrent ? 'var(--cth-paper-100)' : 'transparent', - boxShadow: isCurrent + padding: 8, cursor: 'pointer', minWidth: 0, width: '100%', + background: isCurrent || isPick ? 'var(--cth-paper-100)' : 'transparent', + boxShadow: isCurrent || isPick ? 'inset 0 0 0 1.5px var(--cth-ink-500)' : 'inset 0 0 0 1px var(--cth-ink-300)', - opacity: busy && !isCurrent ? 0.6 : 1, }; const swatch = ( ); @@ -138,25 +114,18 @@ export function OfficeThemePicker({ config }: { config: HarnessConfig }) { {theme.label} - {isCurrent && ( - - {t('officeTheme.current')} - - )} - {!theme.built && !isCurrent && ( - - {t('officeTheme.soon')} - - )} + {isCurrent && {t('officeTheme.current')}} + {isPick && {t('officeTheme.onSave')}} + {!built && !isCurrent && !isPick && {t('officeTheme.soon')}} - {theme.blurb} + {blurb} ); if (!credit) { return ( - @@ -169,11 +138,7 @@ export function OfficeThemePicker({ config }: { config: HarnessConfig }) { @@ -194,9 +159,9 @@ export function OfficeThemePicker({ config }: { config: HarnessConfig }) { })}
- {note && ( -
{note}
- )} + {/* What Save will do; or why the last Save kept the old floor. */} + {pick && pickLabel &&
{t('officeTheme.keepsAgents', { label: pickLabel })}
} + {!pick && notSaved &&
{t('officeTheme.notSaved')}
} {/* Experimental feature flag: adds the TV-show floors to the cards above */}
@@ -212,94 +177,6 @@ export function OfficeThemePicker({ config }: { config: HarnessConfig }) { {enabled ? t('common.on') : t('common.off')}
- - {pending && pendingMeta && ( - setPending(null)} - onConfirm={() => void applyTheme(pending)} - /> - )} -
- ); -} - -interface VictimAgent { id: string; status?: string; } - -/** Destructive confirm for a theme switch with live workers (report §E copy). */ -function ThemeSwitchConfirmModal({ - label, agents, busy, onCancel, onConfirm, -}: { - label: string; - agents: VictimAgent[]; - busy: boolean; - onCancel: () => void; - onConfirm: () => void; -}) { - const { t } = useTranslation(); - const n = agents.length; - const working = agents.filter((a) => a.status && !['idle', 'success', 'error'].includes(a.status)).length; - const godName = useStore.getState().agents.find((a) => a.isGod)?.name ?? 'the orchestrator'; - - return ( -
-
e.stopPropagation()} style={{ width: 480, maxWidth: '92vw' }}> - -
-
-
- -
-
-
- {t('officeTheme.startsFreshCast')} -
-
- {n === 1 - ? t('officeTheme.deleteCount', { count: n }) - : t('officeTheme.deleteCountPlural', { count: n })}{' '} - {t('officeTheme.onlyCarries', { god: godName })} - {working > 0 && ( - - ⚠ {working === 1 - ? t('officeTheme.stillWorking', { count: working }) - : t('officeTheme.stillWorkingPlural', { count: working })} - - )} -
-
- {t('officeTheme.cantUndo')} -
-
-
- -
- - {t('common.cancel')} - - - {busy ? t('officeTheme.switching') : t('officeTheme.deleteSwitch', { count: n })} - -
-
-
-
); } diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index 349d3731f..b7751e344 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -18,6 +18,7 @@ import { SettingsHeroCard } from './SettingsHeroCard'; import { SetupPanel } from './SetupPanel'; import { Icon } from './Icon'; import { OfficeThemePicker } from './OfficeThemePicker'; +import { floorSceneTheme, resolveProjectTheme, type ProjectThemeId } from '@shared/projectTheme'; import { McpDefaultsSettings } from './McpDefaultsSettings'; import { IntegrationsRegistry } from './IntegrationsRegistry'; import { AiEnginesSettings } from './AiEnginesSettings'; @@ -250,9 +251,14 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal const [autoCompactPending, setAutoCompactPending] = useState(null); const [saveBusy, setSaveBusy] = useState(false); const [saveNote, setSaveNote] = useState(''); + // This project's floor theme. A picked card waits for Save like every setting + // here; main stores it and reads it back, so the theme in force is main's answer. + const [projectThemeNow, setProjectThemeNow] = useState(() => resolveProjectTheme(config, config.harnessHome)); + const [themePick, setThemePick] = useState(null); + const [themeNotSaved, setThemeNotSaved] = useState(false); /** True once a control that USED to persist on click has been changed. Only * those need the close guard: the text fields always needed a Save. */ - const dirty = Object.keys(pending).length > 0 || autoCompactPending !== null; + const dirty = Object.keys(pending).length > 0 || autoCompactPending !== null || themePick !== null; const stage = (patch: Partial): void => setPending((prev) => ({ ...prev, ...patch })); @@ -366,8 +372,21 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal await window.cth.updateConfig(patch); setPending({}); setAutoCompactPending(null); - setSaveNote(t('settings.saved')); - setTimeout(() => setSaveNote(''), 1800); + let themeSaved = true; + if (themePick !== null) { + // On 'not-saved' (config.json damaged or busy) the old theme stays in force. + const res = await window.cth.setProjectTheme(themePick); + const cfg = await window.cth.getConfig(); + setProjectThemeNow(res.theme); + useStore.getState().setOfficeTheme(floorSceneTheme(res.theme, cfg.tvShowOffices)); + setThemePick(null); + setThemeNotSaved(!res.ok); + themeSaved = res.ok; + } + if (themeSaved) { + setSaveNote(t('settings.saved')); + setTimeout(() => setSaveNote(''), 1800); + } } catch (e) { setSaveNote(e instanceof Error ? e.message : String(e)); } finally { setSaveBusy(false); } @@ -1182,8 +1201,14 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal
- {/* Office Theme — TV-show office maps (experimental; flag tvShowOffices, default off) */} - + {/* Office Theme: this project's floor (the TV-show maps behind the experimental flag tvShowOffices) */} + { setThemePick(id); setThemeNotSaved(false); }} + notSaved={themeNotSaved} + /> )} diff --git a/src/renderer/src/i18n/locales/ar.json b/src/renderer/src/i18n/locales/ar.json index 2dd107df6..bc4b30bec 100644 --- a/src/renderer/src/i18n/locales/ar.json +++ b/src/renderer/src/i18n/locales/ar.json @@ -180,18 +180,10 @@ "desc": "يضيف خمس أرضيات من المسلسلات إلى القائمة أعلاه.", "current": "الحالي", "soon": "قريبًا", - "notBuiltYet": "{{label}} لم يُبنَ بعد — يُعرَض المكتب في الوقت الحالي.", - "switchAborted": "أُلغي التبديل — تعذّر إغلاق إحدى الطرفيات: {{error}}", - "confirmTitle": "تبديل المكتب إلى \"{{label}}\"؟", - "startsFreshCast": "يبدأ طاقمًا جديدًا", - "deleteCount": "سيُحذف وكيلك الحالي البالغ {{count}} — تُغلق طرفيته ويتوقّف أي عمل جارٍ.", - "deleteCountPlural": "سيُحذف وكلاؤك الحاليون البالغ عددهم {{count}} — تُغلق طرفياتهم ويتوقّف أي عمل جارٍ.", - "onlyCarries": "لا ينتقل سوى {{god}}.", - "stillWorking": "⚠ ما زال {{count}} وكيل يعمل.", - "stillWorkingPlural": "⚠ ما زال {{count}} وكلاء يعملون.", - "cantUndo": "لا يمكن التراجع عن هذا.", - "switching": "جارٍ التبديل…", - "deleteSwitch": "احذف {{count}} وبدِّل" + "onSave": "يتبدل عند الحفظ", + "forProject": "الأرضية الخاصة بـ {{project}}. لكل مشروع أرضيته.", + "keepsAgents": "عند الحفظ ينتقل كل الوكلاء إلى {{label}}. تبقى الأسماء والذاكرة والعمل، ويتغيّر الشكل فقط.", + "notSaved": "لم يُحفظ: تعذّرت قراءة ملف إعداداتك الآن، لذا لم تتغيّر الأرضية." }, "settings": { "title": "الإعدادات", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c0b95cd28..d7a07d123 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -180,18 +180,10 @@ "desc": "Adds five TV show floors to the list above.", "current": "current", "soon": "soon", - "notBuiltYet": "{{label}} isn't built yet — showing the office for now.", - "switchAborted": "Switch aborted — a terminal wouldn't close: {{error}}", - "confirmTitle": "SWITCH OFFICE TO \"{{label}}\"?", - "startsFreshCast": "STARTS A FRESH CAST", - "deleteCount": "Your {{count}} current agent will be deleted — their terminals close and any in-progress work stops.", - "deleteCountPlural": "Your {{count}} current agents will be deleted — their terminals close and any in-progress work stops.", - "onlyCarries": "Only {{god}} carries over.", - "stillWorking": "⚠ {{count}} agent is still working.", - "stillWorkingPlural": "⚠ {{count}} agents are still working.", - "cantUndo": "This can't be undone.", - "switching": "switching…", - "deleteSwitch": "delete {{count}} & switch" + "onSave": "switches on Save", + "forProject": "The floor for {{project}}. Each project keeps its own.", + "keepsAgents": "On Save, every agent moves to {{label}}. Names, memory and work stay; only the look changes.", + "notSaved": "Not saved: your settings file could not be read just now, so the floor did not change." }, "settings": { "title": "SETTINGS", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index a104a2d15..c66f29710 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -180,18 +180,10 @@ "desc": "在上面的列表里加入五个电视剧楼层。", "current": "当前", "soon": "即将推出", - "notBuiltYet": "{{label}} 尚未构建——暂时显示办公室。", - "switchAborted": "切换中止——终端无法关闭:{{error}}", - "confirmTitle": "切换到「{{label}}」?", - "startsFreshCast": "开启全新演员阵容", - "deleteCount": "你的 {{count}} 个当前智能体将被删除——它们的终端会关闭,进行中的工作会停止。", - "deleteCountPlural": "你的 {{count}} 个当前智能体将被删除——它们的终端会关闭,进行中的工作会停止。", - "onlyCarries": "只有 {{god}} 会保留。", - "stillWorking": "⚠ {{count}} 个智能体仍在工作。", - "stillWorkingPlural": "⚠ {{count}} 个智能体仍在工作。", - "cantUndo": "此操作无法撤销。", - "switching": "切换中…", - "deleteSwitch": "删除 {{count}} 个并切换" + "onSave": "按保存后切换", + "forProject": "{{project}} 的楼层。每个项目各用自己的楼层。", + "keepsAgents": "保存后,所有智能体都会移到 {{label}}。名字、记忆和工作都保留,只有外观改变。", + "notSaved": "未保存:现在无法读取你的设置文件,所以楼层没有改变。" }, "settings": { "title": "设置", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index fac048e1c..cfaaa7343 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,6 +5,11 @@ import { ErrorBoundary } from './components/ErrorBoundary'; import brandLogo from '@brand/logo.png?url'; import './design/global.css'; import './i18n'; +import { listThemes } from './scene/themes/themeCatalog'; + +// The valid bundled theme packs join the project-theme catalogue (pickers, the per-project +// theme) before first render: loading the catalogue registers them. +listThemes(); const favicon = document.createElement('link'); favicon.rel = 'icon'; diff --git a/src/renderer/src/scene/themes/themeCatalog.ts b/src/renderer/src/scene/themes/themeCatalog.ts index e1578a0a2..29cb534c1 100644 --- a/src/renderer/src/scene/themes/themeCatalog.ts +++ b/src/renderer/src/scene/themes/themeCatalog.ts @@ -5,6 +5,7 @@ // and `themeProjectDefaults(id)`; applying a seed is the picker's business. import { OFFICE_THEME_ID, type ProjectDefaults, type BuilderSpec } from './manifest'; +import { registerPackThemes } from '@shared/projectTheme'; import { catalogue, finishPack, readPack, type LoadedPack, type PackSummary } from './packs'; import { bundledPacks } from './packSources'; import { normPath, type Diagnostic } from './tiled'; @@ -26,6 +27,7 @@ function summaries(): PackSummary[] { const res = catalogue([...bundledPacks().values()]); for (const r of res.rejected) console.warn(`[themes] pack "${r.dir}" is not usable:\n` + r.diagnostics.map((d) => ` ${d.level}: ${d.message}`).join('\n')); packs = res.packs; + registerPackThemes(packs.map((p) => ({ id: p.id, label: p.label, orchestrator: p.projectDefaults?.orchestrator }))); } return packs; } @@ -43,12 +45,6 @@ export function isStationPack(id: string): boolean { return summaries().some((p) => p.id === id && p.sceneKind === 'stations'); } -/** The floor a saved theme draws: a bundled pack always, a TV-show map only - * while the experimental flag is on, else the office. */ -export function floorSceneTheme(id: string, tvShowOffices: boolean | undefined): string { - return tvShowOffices || isStationPack(id) ? id : OFFICE_THEME_ID; -} - /** A pack's first-project seed (validated data), or null. Only for creating a * blank project: never apply it to a project that already has a roster. */ export function themeProjectDefaults(id: string): ProjectDefaults | null { diff --git a/src/renderer/src/store/config.ts b/src/renderer/src/store/config.ts index 4931b9a81..ca32f80cb 100644 --- a/src/renderer/src/store/config.ts +++ b/src/renderer/src/store/config.ts @@ -123,8 +123,11 @@ export interface HarnessConfig { knowledgeGraph?: KnowledgeGraphConfig; /** TV-show office themes feature flag (Settings picker + switch flow). Default OFF. */ tvShowOffices?: boolean; - /** Active office map/cast theme (a TV-show map is honored only when tvShowOffices is on; a pack always). */ + /** The floor theme of a project with no entry in officeThemeByHive (a TV-show map is + * honored only when tvShowOffices is on; a pack always). Read with resolveProjectTheme. */ officeTheme?: 'office' | 'friends' | 'brooklyn99' | 'siliconvalley' | 'got' | 'hogwarts' | 'starhike'; + /** Each project's own floor theme, keyed by its home. Saved only through setProjectTheme. */ + officeThemeByHive?: Record; /** Per-CLI-provider local/self-hosted base URL (Ollama/LM Studio/vLLM, …) for the * OpenCode/Crush/pi/qwen engines; applied at spawn. API KEYS are NOT stored here — * they live write-only in the secret broker. */ From c7f3cf365c21cc9b80cefc3f95490740d0ec6091 Mon Sep 17 00:00:00 2001 From: Chaitanya Giri <16ucc028@lnmiit.ac.in> Date: Mon, 19 Oct 2026 12:11:51 +0530 Subject: [PATCH 31/42] feat(ui): pick the floor when a new project starts A new project, from the folder picker or from Settings' Start fresh, now asks which floor it gets: the Office or a theme pack. Each row shows the floor and the orchestrator who starts there; the choice goes to main with the fresh start. A folder main refuses (not empty, or it cannot be read) shows in a row under the list and create waits, and Settings keeps its local state until main has said yes. Ported from Prozzta/guppyos 02d0d50b (MIT) Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/components/HivePicker.tsx | 82 +++++++++++- .../src/components/ProjectThemeChoice.tsx | 118 ++++++++++++++++++ src/renderer/src/components/SettingsModal.tsx | 51 +++++++- src/renderer/src/i18n/locales/ar.json | 13 ++ src/renderer/src/i18n/locales/en.json | 13 ++ src/renderer/src/i18n/locales/zh-CN.json | 13 ++ 6 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/ProjectThemeChoice.tsx diff --git a/src/renderer/src/components/HivePicker.tsx b/src/renderer/src/components/HivePicker.tsx index d57819fb5..5d7022a50 100644 --- a/src/renderer/src/components/HivePicker.tsx +++ b/src/renderer/src/components/HivePicker.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { PixelPanel } from './PixelPanel'; import { PixelButton } from './PixelButton'; import { Icon } from './Icon'; import type { HarnessConfig } from '@/store/config'; +import { ProjectFolderError, ProjectThemeChoice, projectFolderError } from './ProjectThemeChoice'; +import type { ProjectThemeId } from '@shared/projectTheme'; export interface HivePickerProps { config: HarnessConfig; @@ -32,6 +35,8 @@ export function HivePicker({ config, onOpenCurrent }: HivePickerProps) { const recents = (config.recentHives ?? []).filter((h) => h && h !== current); const [busy, setBusy] = useState(); const [error, setError] = useState(); + // "create new config…": the folder chosen for a new project, while its floor is picked. + const [newProject, setNewProject] = useState(null); // Open a hive. Same folder as the current one → just enter it (no relaunch). // A different folder → changeHome('fresh') re-points + relaunches the process. @@ -63,6 +68,13 @@ export function HivePicker({ config, onOpenCurrent }: HivePickerProps) { else if (res.error !== 'cancelled') setError(res.error); }; + const createNew = async () => { + setError(undefined); + const res = await window.cth.chooseFolder(); + if (res.ok) setNewProject(res.path); + else if (res.error !== 'cancelled') setError(res.error); + }; + return (
-
+
+ {newProject ? ( + setNewProject(null)} /> + ) : (

@@ -170,7 +185,7 @@ export function HivePicker({ config, onOpenCurrent }: HivePickerProps) { open existing config… - + create new config… @@ -178,7 +193,70 @@ export function HivePicker({ config, onOpenCurrent }: HivePickerProps) {

+ )}
); } + +/** Pam's P2: a new project's floor, picked before the project is created. A click + * picks, "create project" acts; The Office is the default. Main refuses a folder + * that is not empty before anything changes, and stores the floor for the new home. */ +function NewProjectDialog({ path, onCancel }: { path: string; onCancel: () => void }) { + const { t } = useTranslation(); + const [pick, setPick] = useState('office'); + const [busy, setBusy] = useState(false); + const [folderError, setFolderError] = useState<'not-empty' | 'unreadable' | null>(null); + const [error, setError] = useState(); + + const create = async () => { + setBusy(true); + setError(undefined); + try { + window.localStorage.setItem(SKIP_KEY, '1'); + const res = await window.cth.changeHome(path, 'fresh', pick); + // Success never returns (the process relaunches). A return means an error. + if (!res.ok) { + window.localStorage.removeItem(SKIP_KEY); + const refused = projectFolderError(res.error); + if (refused) setFolderError(refused); + else setError(res.error ?? 'Could not open that folder.'); + setBusy(false); + } + } catch (e) { + window.localStorage.removeItem(SKIP_KEY); + setError(e instanceof Error ? e.message : String(e)); + setBusy(false); + } + }; + + return ( + +
+

+ {t('projectTheme.intro')} +

+

+ {t('projectTheme.folder', { path })} +

+ + {folderError &&
} + {error && ( +
{error}
+ )} +
+ + {t('common.cancel')} + + {/* A refused folder: the button waits and the line above says why. */} + + {t('projectTheme.create')} + +
+
+
+ ); +} diff --git a/src/renderer/src/components/ProjectThemeChoice.tsx b/src/renderer/src/components/ProjectThemeChoice.tsx new file mode 100644 index 000000000..4b4cf13fe --- /dev/null +++ b/src/renderer/src/components/ProjectThemeChoice.tsx @@ -0,0 +1,118 @@ +import type { CSSProperties, KeyboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SpritePortrait } from './SpritePortrait'; +import { listThemes, themeBuilder, themeProjectDefaults } from '@/scene/themes/themeCatalog'; +import { orchestratorDefaultLook } from '@/scene/themes/looks'; +import { offeredProjectThemes, type ProjectThemeId } from '@shared/projectTheme'; +import { resolveGodName } from '@shared/godIdentity'; + +/** A new project's orchestrator: a fresh hive has no saved name, so its first start + * shows what resolveGodName gives for none. The pack never names it (only its look + * comes from the pack). */ +const NEW_GOD_NAME = resolveGodName(null); + +export interface ProjectThemeChoiceProps { + pick: ProjectThemeId; + onPick: (id: ProjectThemeId) => void; + disabled?: boolean; +} + +/** The floors a new project can start on (Pam's P2): The Office and every pack. + * The TV-show floors stay a Settings choice. A pack's row shows the look its + * orchestrator starts with, and the pack's art credit. */ +export function ProjectThemeChoice({ pick, onPick, disabled }: ProjectThemeChoiceProps) { + const { t } = useTranslation(); + const credits = new Map(listThemes().filter((x) => !x.builtIn).map((x) => [x.id, x.credit])); + const themes = offeredProjectThemes(false); + + const line: CSSProperties = { fontSize: 12, lineHeight: '16px', color: 'var(--cth-ink-500)' }; + return ( +
+ {themes.map((theme) => { + const selected = theme.id === pick; + const isOffice = theme.id === 'office'; + const credit = credits.get(theme.id); + const seed = isOffice ? undefined : themeProjectDefaults(theme.id)?.orchestrator; + const look = seed ? orchestratorDefaultLook(themeBuilder(theme.id) ?? undefined, undefined, seed.appearance) : null; + const choose = () => { if (!disabled) onPick(theme.id); }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choose(); } }; + return ( +
+ {isOffice ? ( + + ) : ( + + + + )} + + + {theme.label} + + + {isOffice ? t('projectTheme.office') : theme.id === 'starhike' ? t('projectTheme.starhike') : theme.label} + + + {theme.id === 'starhike' ? t('projectTheme.captain', { god: NEW_GOD_NAME }) : t('projectTheme.starts', { god: NEW_GOD_NAME })} + + {credit && ( + { e.preventDefault(); e.stopPropagation(); void window.cth.openExternal(credit.url); }} + style={{ fontSize: 11, lineHeight: '14px', color: 'var(--cth-ink-500)', textDecoration: 'underline', textUnderlineOffset: 2, alignSelf: 'flex-start', cursor: 'pointer' }} + > + {credit.text} + + )} + +
+ ); + })} +
+ ); +} + +/** Why a new project's folder was refused (Pam's P2): a tag and one line, no red. + * `error` is main's id from changeHome; other errors are not this row's. */ +export function ProjectFolderError({ error }: { error: 'not-empty' | 'unreadable' }) { + const { t } = useTranslation(); + return ( +
+ + {t('projectTheme.errTag')} + + {t(error === 'not-empty' ? 'projectTheme.notEmpty' : 'projectTheme.unreadable')} +
+ ); +} + +/** Main's id for a refused new-project folder, or null for any other answer. */ +export function projectFolderError(error: string | undefined): 'not-empty' | 'unreadable' | null { + return error === 'not-empty' || error === 'unreadable' ? error : null; +} diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index b7751e344..59c066802 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -18,6 +18,7 @@ import { SettingsHeroCard } from './SettingsHeroCard'; import { SetupPanel } from './SetupPanel'; import { Icon } from './Icon'; import { OfficeThemePicker } from './OfficeThemePicker'; +import { ProjectFolderError, ProjectThemeChoice, projectFolderError } from './ProjectThemeChoice'; import { floorSceneTheme, resolveProjectTheme, type ProjectThemeId } from '@shared/projectTheme'; import { McpDefaultsSettings } from './McpDefaultsSettings'; import { IntegrationsRegistry } from './IntegrationsRegistry'; @@ -163,6 +164,23 @@ function clearLocalState(): void { } catch { /* noop */ } } +/** The cth.* keys clearLocalState removes, to put back when main refuses the change. */ +function snapshotLocalState(): [string, string][] { + const kept: [string, string][] = []; + try { + for (let i = 0; i < window.localStorage.length; i++) { + const k = window.localStorage.key(i); + const v = k && k.startsWith('cth.') ? window.localStorage.getItem(k) : null; + if (k && v !== null) kept.push([k, v]); + } + } catch { /* noop */ } + return kept; +} + +function restoreLocalState(kept: [string, string][]): void { + try { for (const [k, v] of kept) window.localStorage.setItem(k, v); } catch { /* noop */ } +} + // v0.3.4 redesign: six tabs, one topic each. 'AI Engines' folded into // Agents & Models; MCP + Slack + webhook + REST live together in Connections; // voice gets its own tab; Danger Zone became a red row at the bottom of General. @@ -207,6 +225,10 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal const [changeMode, setChangeMode] = useState<'move' | 'fresh'>('move'); const [changeBusy, setChangeBusy] = useState(false); const [changeErr, setChangeErr] = useState(''); + // Start fresh makes a new project: its floor (Pam's P2), and main's refusal of a + // folder that is not empty or cannot be read. + const [freshTheme, setFreshTheme] = useState('office'); + const [changeFolderErr, setChangeFolderErr] = useState<'not-empty' | 'unreadable' | null>(null); // `notifications` is an optional field on the main-process config; the renderer // mirror type may not declare it yet, so read it defensively. @@ -804,6 +826,8 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal const res = await window.cth.chooseFolder(); if (!res.ok) return; // cancelled - no-op setChangeMode('move'); // recommended default + setFreshTheme('office'); + setChangeFolderErr(null); setChangeHome(res.path); }; @@ -815,12 +839,21 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal // Moving copies the hive (incl. its .git) + palace, so the new home owns the // same renderer-side roster - keep localStorage. A 'fresh' home starts empty, // so clear the renderer cache to match. - if (changeMode === 'fresh') clearLocalState(); + // Main may refuse the folder and keep this window, so the cache is put back then. + const kept = changeMode === 'fresh' ? snapshotLocalState() : null; + if (kept) clearLocalState(); try { - const res = await window.cth.changeHome(changeHome, changeMode); - if (!res.ok) { setChangeErr(res.error ?? 'Could not change the home folder.'); setChangeBusy(false); } + const res = await window.cth.changeHome(changeHome, changeMode, changeMode === 'fresh' ? freshTheme : undefined); + if (!res.ok) { + if (kept) restoreLocalState(kept); + const refused = projectFolderError(res.error); + if (refused) setChangeFolderErr(refused); + else setChangeErr(res.error ?? 'Could not change the home folder.'); + setChangeBusy(false); + } // ok === true never returns (the process relaunches). } catch (e) { + if (kept) restoreLocalState(kept); setChangeErr(e instanceof Error ? e.message : String(e)); setChangeBusy(false); } @@ -878,7 +911,7 @@ export function SettingsModal({ config, onClose, initialSection }: SettingsModal + ); + })} + + )} + + {closed ? ( + <> + {notSentRow(t('askMe.gone'))} + {keptBox} + + ) : ( + <> +