Compare commits

...
8 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
20 changed files with 410 additions and 348 deletions
+7
View File
@@ -1,5 +1,12 @@
# 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
+1 -1
View File
@@ -1 +1 @@
sha256-mF8N1sBkSt2/ZgtB511lqv1knlQ0lmicZxsoucbWZfE=
sha256-o8d/nX0FdM6QB7c/RWpo1USsz8UNmwohSqkS7tflayc=
+25 -25
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.5.1",
"version": "0.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.5.1",
"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.1",
"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.1",
"version": "0.5.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.5.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/server": "0.5.1",
"@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.1",
"version": "0.5.2",
"dependencies": {
"@getpaseo/protocol": "0.5.1",
"@getpaseo/relay": "0.5.1",
"@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.1",
"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.1",
"version": "0.5.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38597,7 +38597,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.5.1",
"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.1",
"version": "0.5.2",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.5.1",
"@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.1",
"@getpaseo/client": "0.5.2",
"react": "19.1.0",
"zod": "^4.4.3"
}
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38660,7 +38660,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.5.1",
"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.1",
"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.1",
"@getpaseo/highlight": "0.5.1",
"@getpaseo/plugin": "0.5.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/relay": "0.5.1",
"@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.1",
"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.1",
"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.1",
"version": "0.5.2",
"private": true,
"main": "index.ts",
"scripts": {
+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.1",
"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.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/server": "0.5.1",
"@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.1",
"version": "0.5.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.5.1",
"@getpaseo/relay": "0.5.1",
"@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.1",
"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.1",
"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.1",
"version": "0.5.2",
"files": [
"dist",
"!dist/**/*.map"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.5.1",
"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.1",
"@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.1",
"@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.1",
"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.1",
"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.1",
"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.1",
"@getpaseo/highlight": "0.5.1",
"@getpaseo/plugin": "0.5.1",
"@getpaseo/protocol": "0.5.1",
"@getpaseo/relay": "0.5.1",
"@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.1",
"version": "0.5.2",
"private": true,
"type": "module",
"scripts": {