Compare commits

...
12 Commits
Author SHA1 Message Date
Mohamed Boudra 4092c29265 chore(release): cut 0.5.2 2026-08-24 14:30:57 +02:00
Mohamed Boudra 5cedc210a8 docs: add 0.5.2 changelog 2026-08-24 14:27:37 +02:00
Mohamed Boudra 4724d1d9b7 fix(settings): preserve valid legacy font fallback 2026-08-24 14:27:22 +02:00
Mohamed Boudra 8fdca94ead Fix settings resets across app revisions (#3787)
* fix(settings): preserve settings from newer builds

Strict blob validation treated a single newer setting as corruption and then rewrote defaults. Read settings field by field and retain unrecognized data when materializing or migrating known preferences.

* refactor(settings): unify discarded-value warnings

Emit settings warnings where each stored value is parsed so validation cannot drift from diagnostics.

* refactor(settings): validate persisted settings declaratively

Use one loose Zod schema with field defaults so mixed-version settings remain loadable without parallel validation paths.

* fix(settings): preserve explicit services preferences

Keep compatibility fallbacks from overriding explicit settings and prevent read metadata from reaching migration writes.

* refactor(settings): derive migration persistence types

Let the schema prove normalized settings coverage and carry its persisted shape through migrations.
2026-08-24 12:10:13 +02:00
Mohamed Boudra 88f410567d Revert "fix(server): honor Claude bypass permission mode (#3732)"
This reverts commit f998ee1691.
2026-08-24 11:05:19 +02:00
wongangandMohamed Boudra f998ee1691 fix(server): honor Claude bypass permission mode (#3732)
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-24 10:49:12 +02:00
Julien Eid 7a47ec0ee9 Fix crash on Android tablet workspace (#3760)
Fixes #3750
2026-08-24 09:21:25 +02:00
paseo-ai[bot] 4f796181bf fix: update lockfile signatures and Nix hash [skip ci] 2026-08-23 17:46:14 +00:00
Mohamed Boudra f517493591 chore(release): cut 0.5.1 2026-08-23 19:37:03 +02:00
Mohamed Boudra fb74f766c8 docs(changelog): prepare 0.5.1 stable release 2026-08-23 19:32:35 +02:00
Mohamed Boudra e268d1a894 fix(app): stabilize native composer resizing
JavaScript height state competed with native intrinsic layout across draft and agent ownership transitions. Let native inputs own sizing and scrolling while preserving DOM measurement on web.
2026-08-23 18:55:38 +02:00
paseo-ai[bot] b5f5832214 fix: update lockfile signatures and Nix hash [skip ci] 2026-08-23 13:51:42 +00:00
26 changed files with 462 additions and 600 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 0.5.2 - 2026-08-24
### Fixed
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
## 0.5.1 - 2026-08-23
### Fixed
- Fixed multiline composers stopping resizing after workspace and agent transitions on iOS and Android ([#3740](https://github.com/getpaseo/paseo/pull/3740))
## 0.5.0 - 2026-08-23
### Added
+1 -1
View File
@@ -1 +1 @@
sha256-umufh5lfrLUsnvRLzOzw+BYaE/DgH/7x6uGnDwdM4Gk=
sha256-o8d/nX0FdM6QB7c/RWpo1USsz8UNmwohSqkS7tflayc=
+25 -25
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.0",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.0",
"version": "0.5.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36140,7 +36140,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37192,12 +37192,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0",
"@getpaseo/protocol": "0.5.0",
"@getpaseo/server": "0.5.0",
"@getpaseo/client": "0.5.2",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/server": "0.5.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37444,10 +37444,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/relay": "0.5.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37458,7 +37458,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.5.0",
"version": "0.5.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37701,7 +37701,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0",
"version": "0.5.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38626,12 +38626,12 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.0",
"@getpaseo/client": "0.5.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38640,14 +38640,14 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0",
"@getpaseo/client": "0.5.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38878,17 +38878,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0",
"@getpaseo/highlight": "0.5.0",
"@getpaseo/plugin": "0.5.0",
"@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0",
"@getpaseo/client": "0.5.2",
"@getpaseo/highlight": "0.5.2",
"@getpaseo/plugin": "0.5.2",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/relay": "0.5.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -39940,7 +39940,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.5.0",
"version": "0.5.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.5.0",
"version": "0.5.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.5.0",
"version": "0.5.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import { reduceNativeComposerHeight } from "./height-state";
const bounds = { minHeight: 30, maxHeight: 240 };
describe("native composer height", () => {
it("grows a fresh composer from the content size exposed during caret scrolling", () => {
expect(
reduceNativeComposerHeight(30, bounds, {
type: "content-measured",
height: 131,
}),
).toBe(131);
});
it("remeasures a restored multiline draft before applying its intrinsic height", () => {
const invalidatedHeight = reduceNativeComposerHeight(30, bounds, { type: "remeasure" });
expect(invalidatedHeight).toBeGreaterThan(30);
expect(
reduceNativeComposerHeight(invalidatedHeight, bounds, {
type: "content-measured",
height: 173,
}),
).toBe(173);
});
it("shrinks a grown composer after deletion and the resulting native measurement", () => {
const invalidatedHeight = reduceNativeComposerHeight(240, bounds, {
type: "text-changed",
previousText: "one\ntwo\nthree",
nextText: "one",
});
expect(invalidatedHeight).toBeLessThan(240);
expect(
reduceNativeComposerHeight(invalidatedHeight, bounds, {
type: "content-measured",
height: 50,
}),
).toBe(50);
});
it("does not disturb height while text grows", () => {
expect(
reduceNativeComposerHeight(120, bounds, {
type: "text-changed",
previousText: "one",
nextText: "one two",
}),
).toBe(120);
});
it("clamps measurements and resets to the current bounds", () => {
expect(
reduceNativeComposerHeight(30, bounds, {
type: "content-measured",
height: 999,
}),
).toBe(240);
expect(reduceNativeComposerHeight(180, bounds, { type: "reset" })).toBe(30);
});
});
@@ -1,41 +0,0 @@
export interface ComposerHeightBounds {
minHeight: number;
maxHeight: number;
}
export type NativeComposerHeightEvent =
| { type: "content-measured"; height: number }
| { type: "text-changed"; previousText: string; nextText: string }
| { type: "remeasure" }
| { type: "reset" };
export function clampComposerHeight(height: number, bounds: ComposerHeightBounds): number {
return Math.max(bounds.minHeight, Math.min(bounds.maxHeight, height));
}
export function reduceNativeComposerHeight(
currentHeight: number,
bounds: ComposerHeightBounds,
event: NativeComposerHeightEvent,
): number {
if (event.type === "reset") return bounds.minHeight;
if (event.type === "content-measured") {
if (!Number.isFinite(event.height) || event.height <= 0) return currentHeight;
return clampComposerHeight(event.height, bounds);
}
if (event.type === "remeasure") {
const direction = currentHeight <= bounds.minHeight ? 1 : -1;
return clampComposerHeight(currentHeight + direction, bounds);
}
if (event.nextText.length >= event.previousText.length || currentHeight <= bounds.minHeight) {
return currentHeight;
}
// Fabric can retain the old UITextView content size after deletion while the text still fits
// inside its frame. Changing that frame invalidates layout; the following content-size event
// supplies the real smaller height.
return reduceNativeComposerHeight(currentHeight, bounds, { type: "remeasure" });
}
@@ -1,100 +1,18 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMemo } from "react";
import type { RefObject } from "react";
import type {
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputScrollEventData,
TextStyle,
} from "react-native";
import { reduceNativeComposerHeight, type NativeComposerHeightEvent } from "./height-state";
import type { ComposerHeightResult } from "./height.types";
interface ComposerHeightArgs {
value: string;
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onHeightChange?: (height: number) => void;
}
interface ComposerHeightResult {
style: TextStyle;
scrollEnabled: boolean;
onTextChange: (previousText: string, nextText: string) => void;
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
reset: () => void;
}
export function useComposerHeight({
value,
minHeight,
maxHeight,
onHeightChange,
}: ComposerHeightArgs): ComposerHeightResult {
const [height, setHeight] = useState(minHeight);
const heightRef = useRef(minHeight);
const didRemeasureInitialValueRef = useRef(false);
const onHeightChangeRef = useRef(onHeightChange);
onHeightChangeRef.current = onHeightChange;
const apply = useCallback(
(event: NativeComposerHeightEvent) => {
const nextHeight = reduceNativeComposerHeight(
heightRef.current,
{ minHeight, maxHeight },
event,
);
if (Math.abs(nextHeight - heightRef.current) < 1) return;
heightRef.current = nextHeight;
setHeight(nextHeight);
onHeightChangeRef.current?.(nextHeight);
},
[maxHeight, minHeight],
);
useEffect(() => {
apply({ type: "content-measured", height: heightRef.current });
}, [apply]);
useEffect(() => {
if (didRemeasureInitialValueRef.current || value.length === 0) return;
didRemeasureInitialValueRef.current = true;
apply({ type: "remeasure" });
}, [apply, value]);
const onTextChange = useCallback(
(previousText: string, nextText: string) => {
apply({ type: "text-changed", previousText, nextText });
},
[apply],
);
const onContentSizeChange = useCallback(
(event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
apply({ type: "content-measured", height: event.nativeEvent.contentSize.height });
},
[apply],
);
const onScroll = useCallback(
(event: NativeSyntheticEvent<TextInputScrollEventData>) => {
// iOS Fabric can omit the first content-size callback when a fixed-height input overflows.
// The caret-scroll event still carries the actual size and bootstraps the same state.
const nativeEvent = event.nativeEvent as TextInputScrollEventData & {
contentSize?: { width: number; height: number };
};
const contentHeight = nativeEvent.contentSize?.height;
if (contentHeight !== undefined) {
apply({ type: "content-measured", height: contentHeight });
}
},
[apply],
);
const reset = useCallback(() => apply({ type: "reset" }), [apply]);
const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]);
// A non-scrolling UITextView reports its frame as contentSize. Scrolling stays enabled so
// caret overflow can expose the intrinsic size through onScroll.
return { style, scrollEnabled: true, onTextChange, onContentSizeChange, onScroll, reset };
const style = useMemo(() => ({ minHeight, maxHeight }), [maxHeight, minHeight]);
return { mode: "intrinsic", style, scrollEnabled: true };
}
@@ -0,0 +1,15 @@
import type { TextStyle } from "react-native";
export type ComposerHeightResult =
| {
mode: "intrinsic";
style: TextStyle;
scrollEnabled: true;
}
| {
mode: "measured";
style: TextStyle;
scrollEnabled: boolean;
onTextChange: (previousText: string, nextText: string) => void;
reset: () => void;
};
+6 -40
View File
@@ -1,28 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import type {
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputScrollEventData,
TextStyle,
} from "react-native";
import { clampComposerHeight } from "./height-state";
import type { ComposerHeightResult } from "./height.types";
interface ComposerHeightArgs {
value: string;
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onHeightChange?: (height: number) => void;
}
interface ComposerHeightResult {
style: TextStyle;
scrollEnabled: boolean;
onTextChange: (previousText: string, nextText: string) => void;
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
reset: () => void;
}
const COPIED_STYLES = [
@@ -52,28 +36,19 @@ export function useComposerHeight({
textareaRef,
minHeight,
maxHeight,
onHeightChange,
}: ComposerHeightArgs): ComposerHeightResult {
const [height, setHeight] = useState(minHeight);
const heightRef = useRef(minHeight);
const paramsRef = useRef({ value, minHeight, maxHeight, onHeightChange });
paramsRef.current = { value, minHeight, maxHeight, onHeightChange };
const paramsRef = useRef({ value, minHeight, maxHeight });
paramsRef.current = { value, minHeight, maxHeight };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
const setBoundedHeight = useCallback((nextHeight: number) => {
const {
minHeight: currentMin,
maxHeight: currentMax,
onHeightChange: onChange,
} = paramsRef.current;
const bounded = clampComposerHeight(nextHeight, {
minHeight: currentMin,
maxHeight: currentMax,
});
const { minHeight: currentMin, maxHeight: currentMax } = paramsRef.current;
const bounded = Math.max(currentMin, Math.min(currentMax, nextHeight));
if (Math.abs(heightRef.current - bounded) < 1) return;
heightRef.current = bounded;
setHeight(bounded);
onChange?.(bounded);
}, []);
const measure = useCallback(
@@ -145,22 +120,13 @@ export function useComposerHeight({
[measure],
);
const reset = useCallback(() => setBoundedHeight(minHeight), [minHeight, setBoundedHeight]);
const onContentSizeChange = useCallback(
(_event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => undefined,
[],
);
const onScroll = useCallback(
(_event: NativeSyntheticEvent<TextInputScrollEventData>) => undefined,
[],
);
const style = useMemo(() => ({ height, minHeight, maxHeight }), [height, maxHeight, minHeight]);
return {
mode: "measured",
style,
scrollEnabled: height >= maxHeight,
onTextChange,
onContentSizeChange,
onScroll,
reset,
};
}
+21 -22
View File
@@ -4,10 +4,9 @@ import {
Text,
useWindowDimensions,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
TextInputScrollEventData,
TextInputSelectionChangeEventData,
type LayoutChangeEvent,
} from "react-native";
import {
useState,
@@ -640,8 +639,6 @@ interface ComposerTextSurfaceProps {
editable: boolean;
scrollEnabled: boolean;
autoFocus: boolean;
onContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
onScroll: (event: NativeSyntheticEvent<TextInputScrollEventData>) => void;
onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined;
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
@@ -680,8 +677,6 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
style={props.textInputStyle}
multiline
scrollEnabled={props.scrollEnabled}
onContentSizeChange={props.onContentSizeChange}
onScroll={props.onScroll}
editable={props.editable}
onKeyPress={props.onKeyPress}
onSelectionChange={props.onSelectionChange}
@@ -1216,20 +1211,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacementKey);
const webTextareaRef = useRef<HTMLElement | null>(null);
const {
style: composerHeightStyle,
scrollEnabled: isComposerScrollEnabled,
onTextChange: updateComposerHeightForText,
onContentSizeChange: handleComposerContentSizeChange,
onScroll: handleComposerScroll,
reset: resetComposerHeight,
} = useComposerHeight({
const composerHeight = useComposerHeight({
value,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
onHeightChange,
});
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
@@ -1240,7 +1236,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
updateComposerHeightForText(valueRef.current, nextText);
updateComposerHeightForText?.(valueRef.current, nextText);
valueRef.current = nextText;
updateLiveTextPresence(nextText);
selectionRef.current = selection ?? { start: nextText.length, end: nextText.length };
@@ -1293,7 +1289,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
useEffect(() => {
if (appliedTextReplacementKeyRef.current === textReplacementKey) return;
appliedTextReplacementKeyRef.current = textReplacementKey;
updateComposerHeightForText(valueRef.current, value);
updateComposerHeightForText?.(valueRef.current, value);
valueRef.current = value;
updateLiveTextPresence(value);
textInputRef.current?.replaceText(value);
@@ -1494,7 +1490,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
]);
const minimizeInputHeight = useCallback(() => {
resetComposerHeight();
resetComposerHeight?.();
}, [resetComposerHeight]);
const handleSendMessage = useCallback(() => {
@@ -1664,7 +1660,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback(
(nextValue: string) => {
updateComposerHeightForText(valueRef.current, nextValue);
updateComposerHeightForText?.(valueRef.current, nextValue);
valueRef.current = nextValue;
updateLiveTextPresence(nextValue);
onChangeText(nextValue);
@@ -1776,7 +1772,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
<View
ref={rootRef}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
@@ -1805,8 +1806,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={false}
onContentSizeChange={handleComposerContentSizeChange}
onScroll={handleComposerScroll}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
+4 -4
View File
@@ -5,9 +5,9 @@ export const LEGACY_SETTINGS_KEY = "@paseo:settings";
/**
* The applied-migration marker deliberately sits outside the settings blob:
* `StoredAppSettingsSchema` is a strict object and `readValidatedJson` clears the value when
* parsing fails, so a new field there would wipe every setting for anyone who downgrades to a
* client that predates it. Keeping the marker as a list of ids also means the next migration is
* a new entry rather than a schema change.
* App settings preserve fields newer builds understand, but this marker remains separate so
* applying a migration never becomes an app-settings field that older builds must interpret.
* Keeping the marker as a list of ids also means the next migration is a new entry rather than
* a schema change.
*/
export const SETTINGS_MIGRATIONS_KEY = "@paseo:settings-migrations";
@@ -1,7 +1,7 @@
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
import { APP_SETTINGS_KEY, SETTINGS_MIGRATIONS_KEY } from "./keys";
import type { AppSettings, KeyValueStorage } from "./storage";
import type { AppSettings, KeyValueStorage, PersistedAppSettings } from "./storage";
const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) });
@@ -22,9 +22,14 @@ const STEER_DEFAULT_MIGRATION = "steer-default";
export async function migrateAppSettings(
settings: AppSettings,
storage: KeyValueStorage,
stored?: PersistedAppSettings,
): Promise<AppSettings> {
const stored = await readValidatedJson(storage, SETTINGS_MIGRATIONS_KEY, AppliedMigrationsSchema);
const applied = new Set(stored?.applied ?? []);
const migrationMarker = await readValidatedJson(
storage,
SETTINGS_MIGRATIONS_KEY,
AppliedMigrationsSchema,
);
const applied = new Set(migrationMarker?.applied ?? []);
if (applied.has(STEER_DEFAULT_MIGRATION)) {
return settings;
}
@@ -32,7 +37,15 @@ export async function migrateAppSettings(
const migrated: AppSettings =
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
if (migrated !== settings) {
await storage.setItem(APP_SETTINGS_KEY, JSON.stringify(migrated));
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
await storage.setItem(
APP_SETTINGS_KEY,
JSON.stringify({
...stored,
...migrated,
sidebarRowItems: { ...storedSidebarRowItems, ...migrated.sidebarRowItems },
}),
);
}
applied.add(STEER_DEFAULT_MIGRATION);
@@ -50,6 +50,28 @@ describe("loadAppSettingsFromStorage", () => {
});
expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer");
});
it("keeps valid settings when another build wrote unknown fields or enum values", async () => {
const stored = {
theme: "dark",
contentFontSize: 16,
sendBehavior: "future-mode",
futureSetting: { enabled: true },
sidebarRowItems: { host: false, futureRowItem: true },
};
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify(stored),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.theme).toBe("dark");
expect(result.sendBehavior).toBe(DEFAULT_CLIENT_SETTINGS.sendBehavior);
expect(result.sidebarRowItems.host).toBe(false);
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(stored);
});
it("migrates a stored interrupt to steer and persists it", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -63,6 +85,32 @@ describe("loadAppSettingsFromStorage", () => {
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}").sendBehavior).toBe(
"steer",
);
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}")).not.toHaveProperty(
"needsWrite",
);
});
it("keeps an explicit services choice over the legacy scripts fallback", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({
contentFontSize: 16,
sidebarRowItems: { scripts: false, services: true },
}),
}),
});
expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true);
await saveAppSettings({
queryClient: new QueryClient(),
updates: {
sidebarRowItems: { ...DEFAULT_SIDEBAR_ROW_ITEMS, services: true },
},
deps,
});
expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true);
});
it("keeps an interrupt the user picked after the migration ran", async () => {
@@ -108,8 +156,8 @@ describe("loadAppSettingsFromStorage", () => {
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(
JSON.stringify(DEFAULT_CLIENT_SETTINGS),
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
DEFAULT_CLIENT_SETTINGS,
);
});
@@ -235,7 +283,11 @@ describe("loadAppSettingsFromStorage", () => {
theme: "dark",
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
});
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
manageBuiltInDaemon: false,
releaseChannel: "beta",
...result,
});
});
it("preserves the legacy key's explicit interface size as content size", async () => {
@@ -400,6 +452,36 @@ describe("loadSettingsFromStorage", () => {
});
describe("saveAppSettings", () => {
it("round-trips fields written by a newer build", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({
theme: "dark",
contentFontSize: 16,
sendBehavior: "future-mode",
futureSetting: { enabled: true },
sidebarRowItems: { host: false, futureRowItem: true },
}),
}),
});
await loadAppSettingsFromStorage(deps);
await saveAppSettings({
queryClient: new QueryClient(),
updates: { theme: "light" },
deps,
});
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
theme: "light",
futureSetting: { enabled: true },
sidebarRowItems: {
host: false,
futureRowItem: true,
},
});
});
it("saves terminal scrollback through app settings persistence", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -609,7 +691,7 @@ describe("appearance settings", () => {
expect(result.uiBaseFontSize).toBe(baseSize);
expect(persisted).toMatchObject({ uiBaseFontSize: baseSize });
expect(persisted).not.toHaveProperty("uiFontSize");
expect(persisted.uiFontSize).toBe(legacySize);
},
);
@@ -623,6 +705,20 @@ describe("appearance settings", () => {
expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(16);
});
it("falls back to a valid legacy interface scale when the explicit base size is invalid", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: "abc", uiFontSize: 17 }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
expect(result.uiBaseFontSize).toBe(15);
expect(persisted).toMatchObject({ uiBaseFontSize: 15, uiFontSize: 17 });
});
it("clamps the UI base font size into range and rejects non-numeric values", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
+202 -263
View File
@@ -2,16 +2,14 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
import type { AppLanguage } from "@/i18n/locales";
import {
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
parseSidebarChecksDisplay,
type SidebarChecksDisplay,
} from "@/components/sidebar/display-preferences/checks-display";
import {
DEFAULT_SIDEBAR_ROW_ITEMS,
isChecksHiddenByLegacyRowItem,
parseSidebarRowItems,
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { isNative } from "@/constants/platform";
@@ -22,7 +20,6 @@ import {
type ThemePreference,
} from "@/styles/theme";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
import { migrateAppSettings } from "./migrations";
@@ -41,17 +38,8 @@ const ThemePreferenceSchema = z.enum([
...THEME_OPTIONS.map((option) => option.name),
PLUGIN_THEME_PREFERENCE,
]);
const VALID_THEMES = new Set<string>(ThemePreferenceSchema.options);
/** Where the theme picker lands when the persisted preference cannot be honoured. */
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
"diff",
"timestamp",
"none",
]);
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
@@ -106,58 +94,6 @@ export interface Settings extends AppSettings {
releaseChannel: ReleaseChannel;
}
// Strict, so every item in SIDEBAR_ROW_ITEMS needs a key here the day it is added: the whole
// settings write is one validation, and one unknown key silently loses every other toggle in it.
// `checks` and `scripts` are gone from the item list and stay here for the COMPAT reads in
// row-items.ts.
const SidebarRowItemsSchema = z.strictObject({
branch: z.boolean().optional(),
project: z.boolean().optional(),
host: z.boolean().optional(),
changeRequest: z.boolean().optional(),
services: z.boolean().optional(),
labels: z.boolean().optional(),
checks: z.boolean().optional(),
scripts: z.boolean().optional(),
});
const StoredAppSettingsSchema = z.strictObject({
theme: ThemePreferenceSchema.optional(),
pluginThemeId: z.string().nullish(),
language: z
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
.optional(),
sendBehavior: z.enum(["interrupt", "steer", "queue"]).optional(),
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(),
terminalScrollbackLines: z.union([z.number(), z.string()]).optional(),
useLegacyTerminalRenderer: z.boolean().optional(),
uiFontFamily: z.string().optional(),
monoFontFamily: z.string().optional(),
uiBaseFontSize: z.union([z.number(), z.string()]).optional(),
contentFontSize: z.union([z.number(), z.string()]).optional(),
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
uiFontSize: z.union([z.number(), z.string()]).optional(),
codeFontSize: z.union([z.number(), z.string()]).optional(),
syntaxTheme: z.string().refine(isSyntaxThemeId).optional(),
workspaceTitleSource: z.enum(["title", "branch"]).optional(),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(),
sidebarRowItems: SidebarRowItemsSchema.optional(),
sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(),
autoExpandReasoning: z.boolean().optional(),
toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(),
compactToolCalls: z.boolean().optional(),
chatOutlineEnabled: z.boolean().optional(),
vimKeybindings: z.boolean().optional(),
openSupportingTabsInSidePanel: z.boolean().optional(),
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
manageBuiltInDaemon: z.boolean().optional(),
releaseChannel: z.enum(["stable", "beta"]).optional(),
});
const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: DEFAULT_THEME_PREFERENCE,
pluginThemeId: null,
@@ -189,6 +125,131 @@ export const DEFAULT_APP_SETTINGS: Settings = {
releaseChannel: "stable",
};
function clampedNumber(min: number, max: number) {
return z
.unknown()
.transform((value) => parseClampedFontSize(value, { min, max }))
.pipe(z.number());
}
function sanitizedFontFamily() {
return z.unknown().transform(sanitizeFontFamily).pipe(z.string());
}
const SidebarRowItemsSchema = z
.looseObject({
branch: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.branch),
project: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.project),
host: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.host),
changeRequest: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.changeRequest),
services: z.boolean().optional().catch(undefined),
labels: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.labels),
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
checks: z.boolean().optional().catch(undefined),
// COMPAT(sidebarRowItemsScripts): migrated in v0.3.0, remove after 2027-08-05.
scripts: z.boolean().optional().catch(undefined),
})
.catch(DEFAULT_SIDEBAR_ROW_ITEMS);
type StoredAppSettingsFallback = AppSettings & {
uiFontSize?: number;
compactToolCalls?: boolean;
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
needsWrite: boolean;
};
const DEFAULT_STORED_APP_SETTINGS = {
...DEFAULT_CLIENT_SETTINGS,
needsWrite: false,
} satisfies StoredAppSettingsFallback;
const StoredAppSettingsSchema = z
.looseObject({
theme: ThemePreferenceSchema.catch(DEFAULT_THEME_PREFERENCE),
pluginThemeId: z.string().nullable().catch(null),
language: z
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
.catch("system"),
sendBehavior: z.enum(["interrupt", "steer", "queue"]).catch("steer"),
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).catch("ask"),
terminalScrollbackLines: clampedNumber(
MIN_TERMINAL_SCROLLBACK_LINES,
MAX_TERMINAL_SCROLLBACK_LINES,
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
useLegacyTerminalRenderer: z.boolean().catch(false),
uiFontFamily: sanitizedFontFamily().catch(""),
monoFontFamily: sanitizedFontFamily().catch(""),
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
.optional()
.catch(undefined),
contentFontSize: clampedNumber(MIN_CONTENT_FONT_SIZE, MAX_CONTENT_FONT_SIZE)
.optional()
.catch(DEFAULT_CONTENT_FONT_SIZE),
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
uiFontSize: clampedNumber(11, 24).optional().catch(undefined),
codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch(
DEFAULT_CODE_FONT_SIZE,
),
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
sidebarRowItems: SidebarRowItemsSchema,
sidebarChecksDisplay: z
.enum(["iconAndText", "icon", "none"])
.optional()
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
autoExpandReasoning: z.boolean().catch(false),
toolCallDetailLevel: z
.enum(["overview", "detailed"])
.or(z.literal("concise").transform(() => "overview" as const))
.optional()
.catch("detailed"),
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
openSupportingTabsInSidePanel: z.boolean().catch(true),
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
manageBuiltInDaemon: z.boolean().optional().catch(undefined),
releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined),
})
.transform((stored) => {
const needsWrite =
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
stored.contentFontSize === undefined;
const uiBaseFontSize =
stored.uiBaseFontSize ??
(stored.uiFontSize === undefined
? DEFAULT_UI_BASE_FONT_SIZE
: Math.round((FONT_SIZE.base * stored.uiFontSize) / 16));
const sidebarChecksDisplay =
stored.sidebarChecksDisplay ??
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
? "none"
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
const toolCallDetailLevel =
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
return {
...stored,
uiBaseFontSize,
contentFontSize: stored.contentFontSize ?? uiBaseFontSize,
sidebarChecksDisplay,
sidebarRowItems: {
...stored.sidebarRowItems,
services:
stored.sidebarRowItems.services ??
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
},
toolCallDetailLevel,
needsWrite,
};
})
.catch(DEFAULT_STORED_APP_SETTINGS);
type StoredAppSettings = z.output<typeof StoredAppSettingsSchema>;
export type PersistedAppSettings = Omit<StoredAppSettings, "needsWrite">;
export interface KeyValueStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
@@ -220,16 +281,22 @@ export async function saveAppSettings(input: {
const current = normalizeAppSettings(storedCurrent);
const next = { ...current, ...input.updates };
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
await writeAppSettings(
input.deps.storage,
(await readSettingsObject(input.deps.storage, APP_SETTINGS_KEY)) ??
StoredAppSettingsSchema.parse({}),
next,
);
}
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
try {
const read = await readAppSettings(deps);
if (read.needsWrite) {
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(read.settings));
await writeAppSettings(deps.storage, read.stored, read.settings);
}
return await migrateAppSettings(read.settings, deps.storage);
const { needsWrite: _needsWrite, ...stored } = read.stored;
return await migrateAppSettings(read.settings, deps.storage, stored);
} catch (error) {
console.error("[AppSettings] Failed to load settings:", error);
throw error;
@@ -242,23 +309,18 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
*/
async function readAppSettings(
deps: SettingsDeps,
): Promise<{ settings: AppSettings; needsWrite: boolean }> {
const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
): Promise<{ settings: AppSettings; needsWrite: boolean; stored: StoredAppSettings }> {
const stored = await readSettingsObject(deps.storage, APP_SETTINGS_KEY);
if (stored) {
return {
settings: normalizeAppSettings(stored),
// COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17.
needsWrite:
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
stored.contentFontSize === undefined,
needsWrite: stored.needsWrite,
stored,
};
}
const legacyStored = await readValidatedJson(
deps.storage,
LEGACY_SETTINGS_KEY,
LegacyRendererSettingsSchema,
);
const legacyStored = await readSettingsObject(deps.storage, LEGACY_SETTINGS_KEY);
if (legacyStored) {
return {
settings: {
@@ -266,10 +328,12 @@ async function readAppSettings(
...pickAppSettingsFromLegacy(legacyStored),
} satisfies AppSettings,
needsWrite: true,
stored: legacyStored,
};
}
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true };
const defaultStored = StoredAppSettingsSchema.parse({});
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true, stored: defaultStored };
}
export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Settings> {
@@ -299,189 +363,27 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
}
export function normalizeAppSettings(value: unknown): AppSettings {
const result = StoredAppSettingsSchema.safeParse(value);
const {
needsWrite: _needsWrite,
manageBuiltInDaemon: _manageBuiltInDaemon,
releaseChannel: _releaseChannel,
compactToolCalls: _compactToolCalls,
uiFontSize: _uiFontSize,
...settings
} = StoredAppSettingsSchema.parse(value);
return settings;
}
function pickAppSettingsFromLegacy(legacy: StoredAppSettings): AppSettings {
const settings = normalizeAppSettings(legacy);
return {
...DEFAULT_CLIENT_SETTINGS,
...pickAppSettings(result.success ? result.data : {}),
...settings,
// The legacy key rendered content on the interface ramp. Freeze that
// rendered value into the new independent preference during migration.
contentFontSize: legacy.uiBaseFontSize,
};
}
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
if (stored.toolCallDetailLevel !== undefined) {
if (
typeof stored.toolCallDetailLevel === "string" &&
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
) {
return stored.toolCallDetailLevel;
}
// COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values
// deliberately follow the unknown-value fallback. Remove after 2027-01-14.
return "overview";
}
if (typeof stored.compactToolCalls === "boolean") {
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
return stored.compactToolCalls ? "overview" : "detailed";
}
return null;
}
function parseStoredSidebarChecksDisplay(stored: StoredAppSettings): SidebarChecksDisplay | null {
const display = parseSidebarChecksDisplay(stored.sidebarChecksDisplay);
if (display !== null) {
return display;
}
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
return isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) ? "none" : null;
}
function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.useLegacyTerminalRenderer === "boolean") {
result.useLegacyTerminalRenderer = stored.useLegacyTerminalRenderer;
}
if (typeof stored.vimKeybindings === "boolean") {
result.vimKeybindings = stored.vimKeybindings;
}
if (typeof stored.chatOutlineEnabled === "boolean") {
result.chatOutlineEnabled = stored.chatOutlineEnabled;
}
if (typeof stored.openSupportingTabsInSidePanel === "boolean") {
result.openSupportingTabsInSidePanel = stored.openSupportingTabsInSidePanel;
}
return result;
}
/**
* The settings whose stored value only has to be a member of a fixed set. Grouped like the
* boolean settings are: the numeric and font settings need real parsing and clamping, these
* need a membership check and nothing else.
*/
function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme;
}
if (
stored.sendBehavior === "interrupt" ||
stored.sendBehavior === "steer" ||
stored.sendBehavior === "queue"
) {
result.sendBehavior = stored.sendBehavior;
}
if (
typeof stored.serviceUrlBehavior === "string" &&
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
) {
result.serviceUrlBehavior = stored.serviceUrlBehavior;
}
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
if (
typeof stored.sidebarWorkspaceTrailing === "string" &&
VALID_SIDEBAR_WORKSPACE_TRAILINGS.has(stored.sidebarWorkspaceTrailing)
) {
result.sidebarWorkspaceTrailing = stored.sidebarWorkspaceTrailing;
}
return result;
}
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
Object.assign(result, pickEnumAppSettings(stored));
if (typeof stored.pluginThemeId === "string") {
result.pluginThemeId = stored.pluginThemeId;
}
if (stored.sidebarRowItems !== undefined) {
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
}
const sidebarChecksDisplay = parseStoredSidebarChecksDisplay(stored);
if (sidebarChecksDisplay !== null) {
result.sidebarChecksDisplay = sidebarChecksDisplay;
}
const language = parseAppLanguage(stored.language);
if (language !== null) {
result.language = language;
}
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
if (terminalScrollbackLines !== null) {
result.terminalScrollbackLines = terminalScrollbackLines;
}
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
if (uiFontFamily !== null) {
result.uiFontFamily = uiFontFamily;
}
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
if (monoFontFamily !== null) {
result.monoFontFamily = monoFontFamily;
}
const uiBaseFontSize = parseClampedFontSize(stored.uiBaseFontSize, {
min: MIN_UI_BASE_FONT_SIZE,
max: MAX_UI_BASE_FONT_SIZE,
});
if (uiBaseFontSize !== null) {
result.uiBaseFontSize = uiBaseFontSize;
} else {
const legacyUiFontSize = parseClampedFontSize(stored.uiFontSize, {
min: 11,
max: 24,
});
if (legacyUiFontSize !== null) {
result.uiBaseFontSize = Math.round((FONT_SIZE.base * legacyUiFontSize) / 16);
}
}
const contentFontSize = parseClampedFontSize(stored.contentFontSize, {
min: MIN_CONTENT_FONT_SIZE,
max: MAX_CONTENT_FONT_SIZE,
});
if (contentFontSize !== null) {
result.contentFontSize = contentFontSize;
} else if (stored.contentFontSize === undefined) {
// Existing content followed the interface ramp. Preserve that rendered size
// once, then persist the independent setting during the read migration.
result.contentFontSize = result.uiBaseFontSize ?? DEFAULT_UI_BASE_FONT_SIZE;
}
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
min: MIN_CODE_FONT_SIZE,
max: MAX_CODE_FONT_SIZE,
});
if (codeFontSize !== null) {
result.codeFontSize = codeFontSize;
}
Object.assign(result, pickBooleanAppSettings(stored));
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
const toolCallDetailLevel = parseToolCallDetailLevel(stored);
if (toolCallDetailLevel !== null) {
result.toolCallDetailLevel = toolCallDetailLevel;
}
return result;
}
function pickAppSettingsFromLegacy(
legacy: z.infer<typeof LegacyRendererSettingsSchema>,
): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
}
const legacyInterfaceSize = pickAppSettings(legacy).uiBaseFontSize;
if (legacyInterfaceSize !== undefined) {
result.uiBaseFontSize = legacyInterfaceSize;
}
// The legacy key rendered content on the interface ramp. Freeze that
// rendered value into the new independent preference during migration.
result.contentFontSize = legacyInterfaceSize ?? DEFAULT_UI_BASE_FONT_SIZE;
return result;
}
export function parseTerminalScrollbackLines(value: unknown): number | null {
let numericValue = NaN;
if (typeof value === "number") {
@@ -548,10 +450,10 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
releaseChannel?: ReleaseChannel;
} = {};
if (typeof stored.manageBuiltInDaemon === "boolean") {
if (stored.manageBuiltInDaemon !== undefined) {
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
}
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
if (stored.releaseChannel !== undefined) {
result.releaseChannel = stored.releaseChannel;
}
@@ -560,11 +462,48 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
async function loadRendererSettingsPayload(
storage: KeyValueStorage,
): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> {
const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema);
): Promise<StoredAppSettings | null> {
const current = await readSettingsObject(storage, APP_SETTINGS_KEY);
if (current) {
return current;
}
return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema);
return readSettingsObject(storage, LEGACY_SETTINGS_KEY);
}
async function readSettingsObject(
storage: KeyValueStorage,
key: string,
): Promise<StoredAppSettings | null> {
const raw = await storage.getItem(key);
if (raw === null) {
return null;
}
let decoded: unknown;
try {
decoded = JSON.parse(raw);
} catch {
console.warn(`[AppSettings] Removing corrupt ${key}: invalid JSON.`);
await storage.removeItem(key);
return null;
}
return StoredAppSettingsSchema.parse(decoded);
}
async function writeAppSettings(
storage: KeyValueStorage,
stored: StoredAppSettings,
settings: AppSettings,
): Promise<void> {
const { needsWrite: _needsWrite, ...persistedStored } = stored;
const storedSidebarRowItems = persistedStored.sidebarRowItems;
await storage.setItem(
APP_SETTINGS_KEY,
JSON.stringify({
...persistedStored,
...settings,
sidebarRowItems: { ...storedSidebarRowItems, ...settings.sidebarRowItems },
}),
);
}
@@ -3854,29 +3854,36 @@ function WorkspaceScreenContent({
) : null}
{shouldRenderDesktopPaneFallback ? (
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={handleCreateNewTab}
onReorderTabs={handleReorderTabsInFocusedPane}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
// The splits path renders tab rows inside WorkspacePanelContent, which is what
// provides NewTabLauncherProvider. This fallback row sits outside it, and its
// new-tab menu content mounts eagerly, so without its own provider every
// non-compact native layout (tablets, foldables in landscape) crashed on mount
// with "NewTabLauncherProvider is required" (#3750).
<NewTabLauncherProvider value={newTabLauncher}>
<WorkspaceDesktopTabsRow
paneId={focusedPaneIdOrUndefined}
isFocused={isRouteFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateNewTab={handleCreateNewTab}
onReorderTabs={handleReorderTabsInFocusedPane}
focusModeEnabled={desktopFocusModeEnabled}
onExitFocusMode={toggleFocusMode}
/>
</NewTabLauncherProvider>
) : null}
<View style={styles.centerContent}>{workspacePanelContent}</View>
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.5.0",
"version": "0.5.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.0",
"@getpaseo/protocol": "0.5.0",
"@getpaseo/server": "0.5.0",
"@getpaseo/client": "0.5.2",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/server": "0.5.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.5.0",
"version": "0.5.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/relay": "0.5.2",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.5.0",
"version": "0.5.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.5.0",
"version": "0.5.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.5.0",
"version": "0.5.2",
"files": [
"dist",
"!dist/**/*.map"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.5.0",
"version": "0.5.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -39,7 +39,7 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.0",
"@getpaseo/client": "0.5.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -48,7 +48,7 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.5.0",
"@getpaseo/client": "0.5.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.5.0",
"version": "0.5.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.5.0",
"version": "0.5.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.5.0",
"version": "0.5.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -72,11 +72,11 @@
"@anthropic-ai/claude-agent-sdk": "0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@babel/parser": "^7.29.8",
"@getpaseo/client": "0.5.0",
"@getpaseo/highlight": "0.5.0",
"@getpaseo/plugin": "0.5.0",
"@getpaseo/protocol": "0.5.0",
"@getpaseo/relay": "0.5.0",
"@getpaseo/client": "0.5.2",
"@getpaseo/highlight": "0.5.2",
"@getpaseo/plugin": "0.5.2",
"@getpaseo/protocol": "0.5.2",
"@getpaseo/relay": "0.5.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.5.0",
"version": "0.5.2",
"private": true,
"type": "module",
"scripts": {