Skip to content

Commit 6f6c9df

Browse files
fix: restore tweak updates and extend timeout default
1 parent 756e78a commit 6f6c9df

5 files changed

Lines changed: 89 additions & 34 deletions

File tree

apps/desktop/src/main/preferences-ipc.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ describe('readPersisted()', () => {
3939
readFileMock.mockRejectedValueOnce(notFound);
4040

4141
const result = await readPersisted();
42-
expect(result).toEqual({ updateChannel: 'stable', generationTimeoutSec: 600 });
42+
expect(result).toEqual({ updateChannel: 'stable', generationTimeoutSec: 1200 });
4343
});
4444

4545
it('honors XDG_CONFIG_HOME when computing the persisted file path', async () => {
@@ -70,12 +70,12 @@ describe('readPersisted()', () => {
7070
expect((err as CodesignError).code).toBe('PREFERENCES_READ_FAILED');
7171
});
7272

73-
it('migrates schemaVersion 1 with legacy 120s timeout to the 600s default', async () => {
73+
it('migrates schemaVersion 1 with legacy 120s timeout to the 1200s default', async () => {
7474
readFileMock.mockResolvedValueOnce(
7575
JSON.stringify({ schemaVersion: 1, updateChannel: 'stable', generationTimeoutSec: 120 }),
7676
);
7777
const result = await readPersisted();
78-
expect(result.generationTimeoutSec).toBe(600);
78+
expect(result.generationTimeoutSec).toBe(1200);
7979
});
8080

8181
it('preserves user-chosen non-legacy timeout across the v1 → v2 migration', async () => {
@@ -86,11 +86,19 @@ describe('readPersisted()', () => {
8686
expect(result.generationTimeoutSec).toBe(300);
8787
});
8888

89-
it('respects an explicit 120s when schema is already v2 (user chose it post-migration)', async () => {
89+
it('migrates schemaVersion 2 with the old 600s default to 1200s', async () => {
9090
readFileMock.mockResolvedValueOnce(
91-
JSON.stringify({ schemaVersion: 2, updateChannel: 'stable', generationTimeoutSec: 120 }),
91+
JSON.stringify({ schemaVersion: 2, updateChannel: 'stable', generationTimeoutSec: 600 }),
9292
);
9393
const result = await readPersisted();
94-
expect(result.generationTimeoutSec).toBe(120);
94+
expect(result.generationTimeoutSec).toBe(1200);
95+
});
96+
97+
it('respects an explicit 600s when schema is already v3 (user chose it post-migration)', async () => {
98+
readFileMock.mockResolvedValueOnce(
99+
JSON.stringify({ schemaVersion: 3, updateChannel: 'stable', generationTimeoutSec: 600 }),
100+
);
101+
const result = await readPersisted();
102+
expect(result.generationTimeoutSec).toBe(600);
95103
});
96104
});

apps/desktop/src/main/preferences-ipc.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ import { getLogger } from './logger';
1717

1818
const logger = getLogger('preferences-ipc');
1919

20-
const SCHEMA_VERSION = 2;
20+
const SCHEMA_VERSION = 3;
2121
// v1 → v2: raise the abandoned 120s timeout default (which aborted real
2222
// agentic runs mid-loop) to 600s. Values that happen to equal the old
2323
// default are treated as unmigrated defaults, not user intent.
24-
const LEGACY_DEFAULT_TIMEOUT_SEC = 120;
24+
const V1_DEFAULT_TIMEOUT_SEC = 120;
25+
// v2 -> v3: 600s still clips slower long-form multi-turn runs, so the default
26+
// moves to 1200s.
27+
const V2_DEFAULT_TIMEOUT_SEC = 600;
2528

2629
function prefsFile(): string {
2730
return join(configDir(), 'preferences.json');
@@ -41,11 +44,10 @@ interface PreferencesFile extends Preferences {
4144
const DEFAULTS: Preferences = {
4245
updateChannel: 'stable',
4346
// Agentic runs do multiple LLM turns + tool executions + file writes, so
44-
// 120s (Workstream B Phase 1 single-turn budget) was too tight and aborted
45-
// real runs mid-loop. 600s (10 min) covers a typical multi-turn design
46-
// generation with a slow coproxy. Users on fast endpoints can lower this
47+
// 120s was too tight and 600s still clips slower long-form runs. Default to
48+
// 1200s (20 min); users on fast endpoints can lower this
4749
// in Settings → Advanced.
48-
generationTimeoutSec: 600,
50+
generationTimeoutSec: 1200,
4951
};
5052

5153
export async function readPersisted(): Promise<Preferences> {
@@ -59,9 +61,11 @@ export async function readPersisted(): Promise<Preferences> {
5961
? parsed.generationTimeoutSec
6062
: DEFAULTS.generationTimeoutSec;
6163
const migratedTimeout =
62-
persistedSchema < SCHEMA_VERSION && rawTimeout === LEGACY_DEFAULT_TIMEOUT_SEC
64+
persistedSchema < 2 && rawTimeout === V1_DEFAULT_TIMEOUT_SEC
6365
? DEFAULTS.generationTimeoutSec
64-
: rawTimeout;
66+
: persistedSchema < 3 && rawTimeout === V2_DEFAULT_TIMEOUT_SEC
67+
? DEFAULTS.generationTimeoutSec
68+
: rawTimeout;
6569
return {
6670
updateChannel:
6771
parsed.updateChannel === 'stable' || parsed.updateChannel === 'beta'

apps/desktop/src/renderer/src/components/PreviewPane.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isTrustedPreviewMessageSource,
66
postModeToPreviewWindow,
77
scaleRectForZoom,
8+
stablePreviewSourceKey,
89
} from './PreviewPane';
910

1011
describe('isTrustedPreviewMessageSource', () => {
@@ -43,6 +44,27 @@ describe('scaleRectForZoom', () => {
4344
});
4445
});
4546

47+
describe('stablePreviewSourceKey', () => {
48+
it('masks EDITMODE and TWEAK_SCHEMA spans for JSX artifacts', () => {
49+
const source = `const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{"accent":"#000"}/*EDITMODE-END*/;
50+
const TWEAK_SCHEMA = /*TWEAK-SCHEMA-BEGIN*/{"accent":{"kind":"color"}}/*TWEAK-SCHEMA-END*/;
51+
function App(){ return <div />; }`;
52+
53+
const key = stablePreviewSourceKey(source);
54+
55+
expect(key).toContain('/*EDITMODE-BEGIN*/__STABLE__/*EDITMODE-END*/');
56+
expect(key).toContain('/*TWEAK-SCHEMA-BEGIN*/__STABLE__/*TWEAK-SCHEMA-END*/');
57+
expect(key).not.toContain('{"accent":"#000"}');
58+
});
59+
60+
it('keeps full HTML documents unstable so token changes force a reload', () => {
61+
const source =
62+
'<!doctype html><html><body><script>const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{"accent":"#000"}/*EDITMODE-END*/;</script></body></html>';
63+
64+
expect(stablePreviewSourceKey(source)).toBe(source);
65+
});
66+
});
67+
4668
describe('handlePreviewMessage trust boundary', () => {
4769
function makeHandlers() {
4870
return {

apps/desktop/src/renderer/src/components/PreviewPane.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ export function scaleRectForZoom(
6969
};
7070
}
7171

72+
export function stablePreviewSourceKey(source: string): string {
73+
const head = source.trimStart().slice(0, 2048).toLowerCase();
74+
// Full HTML documents do not get the JSX tweaks bridge injected, so token
75+
// changes must invalidate srcdoc and force a reload to take effect.
76+
if (head.startsWith('<!doctype') || head.startsWith('<html')) return source;
77+
return source
78+
.replace(
79+
/\/\*\s*EDITMODE-BEGIN\s*\*\/[\s\S]*?\/\*\s*EDITMODE-END\s*\*\//g,
80+
'/*EDITMODE-BEGIN*/__STABLE__/*EDITMODE-END*/',
81+
)
82+
.replace(
83+
/\/\*\s*TWEAK-SCHEMA-BEGIN\s*\*\/[\s\S]*?\/\*\s*TWEAK-SCHEMA-END\s*\*\//g,
84+
'/*TWEAK-SCHEMA-BEGIN*/__STABLE__/*TWEAK-SCHEMA-END*/',
85+
);
86+
}
87+
7288
export type AllowedPreviewMessageType = 'ELEMENT_SELECTED' | 'IFRAME_ERROR';
7389

7490
export interface PreviewMessageHandlers {
@@ -145,17 +161,7 @@ function PreviewSlot({
145161
registerIframe,
146162
onIframeError,
147163
}: PreviewSlotProps) {
148-
const srcDocStableKey = useMemo(() => {
149-
return html
150-
.replace(
151-
/\/\*\s*EDITMODE-BEGIN\s*\*\/[\s\S]*?\/\*\s*EDITMODE-END\s*\*\//g,
152-
'/*EDITMODE-BEGIN*/__STABLE__/*EDITMODE-END*/',
153-
)
154-
.replace(
155-
/\/\*\s*TWEAK-SCHEMA-BEGIN\s*\*\/[\s\S]*?\/\*\s*TWEAK-SCHEMA-END\s*\*\//g,
156-
'/*TWEAK-SCHEMA-BEGIN*/__STABLE__/*TWEAK-SCHEMA-END*/',
157-
);
158-
}, [html]);
164+
const srcDocStableKey = useMemo(() => stablePreviewSourceKey(html), [html]);
159165

160166
// biome-ignore lint/correctness/useExhaustiveDependencies: srcDocStableKey is the intentional dependency. html flows through naturally because the factory closes over it and re-runs whenever the stable key flips, which is exactly when structural changes (anything outside EDITMODE / TWEAK_SCHEMA markers) are present.
161167
const srcDoc = useMemo(() => buildSrcdoc(html), [srcDocStableKey]);

apps/desktop/src/renderer/src/components/TweakPanel.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ type TokenValue = unknown;
1515
type Tokens = Record<string, TokenValue>;
1616

1717
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
18+
const CSS_COLOR_RE =
19+
/^(#([0-9a-f]{3}|[0-9a-f]{6})|(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch)\([^)]*\)|color\([^)]*\)|[a-z]+)$/i;
1820

1921
function isColorString(value: unknown): value is string {
20-
return typeof value === 'string' && HEX_RE.test(value);
22+
return typeof value === 'string' && CSS_COLOR_RE.test(value.trim());
23+
}
24+
25+
function isNativeColorInputValue(value: string): boolean {
26+
return HEX_RE.test(value.trim());
2127
}
2228

2329
function humanize(key: string): string {
@@ -37,21 +43,30 @@ function ColorSwatch({
3743
onChange: (next: string) => void;
3844
pickColorLabel: string;
3945
}) {
46+
const canPickNatively = isNativeColorInputValue(value);
4047
return (
4148
<div className="flex items-center gap-[var(--space-2)]">
42-
<label className="relative inline-flex h-[28px] w-[28px] shrink-0 cursor-pointer overflow-hidden rounded-[var(--radius-sm)] shadow-[var(--shadow-inset-soft)] transition-transform duration-[var(--duration-faster)] hover:scale-[1.04] active:scale-[var(--scale-press-down)]">
49+
<label
50+
className={`relative inline-flex h-[28px] w-[28px] shrink-0 overflow-hidden rounded-[var(--radius-sm)] shadow-[var(--shadow-inset-soft)] transition-transform duration-[var(--duration-faster)] ${
51+
canPickNatively
52+
? 'cursor-pointer hover:scale-[1.04] active:scale-[var(--scale-press-down)]'
53+
: 'cursor-default'
54+
}`}
55+
>
4356
<span
4457
className="block h-full w-full"
4558
style={{ backgroundColor: value }}
4659
aria-hidden="true"
4760
/>
48-
<input
49-
type="color"
50-
value={value}
51-
onChange={(e) => onChange(e.target.value)}
52-
className="absolute inset-0 cursor-pointer opacity-0"
53-
aria-label={pickColorLabel}
54-
/>
61+
{canPickNatively ? (
62+
<input
63+
type="color"
64+
value={value}
65+
onChange={(e) => onChange(e.target.value)}
66+
className="absolute inset-0 cursor-pointer opacity-0"
67+
aria-label={pickColorLabel}
68+
/>
69+
) : null}
5570
</label>
5671
<input
5772
type="text"

0 commit comments

Comments
 (0)