From aeb98f813aae053b8566c9043bfb5fc912cab633 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 22 Sep 2026 20:18:00 +0200 Subject: [PATCH 01/34] feat(claude): add Opus 5.5 with its model capabilities (#5200) feat(claude): add Opus 5.5 with its model capabilities Combine the catalog and runtime identity work from #5191 with the per-model effort default from #5197. Claude Code 2.1.280 introduces Opus 5.5 with 1M context, medium effort, and always-on thinking. Resolve prefixed minor model releases before their major version so Opus 5.5 retains its identity and capabilities after a turn. Co-authored-by: Leonardo G. Urci Co-authored-by: Sebastian Galindo Co-authored-by: Rodrigo --- packages/cli/tests/15-provider.test.ts | 7 +- .../agent/providers/claude/agent.test.ts | 2 +- .../agent/providers/claude/model-manifest.ts | 56 ++++++++++------ .../agent/providers/claude/models.test.ts | 64 ++++++++++++++++++- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index a1d302e9c4..57ace59248 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -52,10 +52,15 @@ interface ProviderDiagnostic { } const EXPECTED_CLAUDE_MODELS = [ + { + id: "claude-opus-5-5", + model: "Opus 5.5", + descriptionFragment: "Latest release", + }, { id: "claude-opus-5", model: "Opus 5", - descriptionFragment: "Latest release", + descriptionFragment: "Previous release", }, { id: "claude-fable-5-1", diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 25d4729dc6..118e93067c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -476,7 +476,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { force: false, }); - expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5"); + expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); expect(models.map((model) => model.id)).toContain("claude-fable-5"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index e7e660a229..c18196c5da 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry { minimumClaudeCodeVersion?: string; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; + defaultThinkingOptionId?: ClaudeEffortLevel; supportsThinkingDisabled?: boolean; supportsFastMode?: boolean; } @@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; export const CLAUDE_MODEL_MANIFEST = [ + { + id: "claude-opus-5-5", + label: "Opus 5.5", + description: "Opus 5.5 · Latest release", + defaultPriority: 3, + minimumClaudeCodeVersion: "2.1.280", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsFastMode: true, + }, { id: "claude-opus-5", label: "Opus 5", - description: "Opus 5 · Latest release", + description: "Opus 5 · Previous release", defaultPriority: 2, minimumClaudeCodeVersion: "2.1.219", contextWindowMaxTokens: 1_000_000, @@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [ }, ] as const satisfies readonly ClaudeModelManifestEntry[]; +function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel { + return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID; +} + function buildThinkingOptions( effortLevels: readonly ClaudeEffortLevel[] | undefined, supportsThinkingDisabled: boolean, + defaultThinkingOptionId: ClaudeEffortLevel, ): AgentSelectOption[] | undefined { if (!effortLevels) { return undefined; @@ -170,7 +187,7 @@ function buildThinkingOptions( ...effortLevels.map((id) => ({ id, label: CLAUDE_EFFORT_LABELS[id], - ...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}), + ...(id === defaultThinkingOptionId ? { isDefault: true } : {}), })), ]; @@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD const thinkingOptions = buildThinkingOptions( model.effortLevels, model.supportsThinkingDisabled === true, + getDefaultThinkingOptionId(model), ); const definition: AgentModelDefinition = { provider: "claude", @@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD } if (thinkingOptions) { definition.thinkingOptions = thinkingOptions; - definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID; + definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model); } definitions.push(definition); if (!("aliases" in model) || !model.aliases) { @@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel( supported: !!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true, fallbackThinkingOptionId: - model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined, + model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined, }; } @@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) /** * Normalize a Claude Code runtime/config model string to a known manifest ID. * Runtime metadata may include provider prefixes such as Bedrock model IDs; feature - * gates should use normalizeClaudeManifestModelId instead. + * gates should use normalizeClaudeManifestModelId instead. The prefixed matches are + * unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the + * "claude-opus-5" entry. */ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null { const normalizedManifestModelId = normalizeClaudeManifestModelId(value); @@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): return null; } - const singleSegmentMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, + const runtimeMatch = trimmed.match( + /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, ); - if (singleSegmentMatch) { - const normalizedModelId = normalizeSingleSegmentClaudeModelId( - singleSegmentMatch[1], - singleSegmentMatch[2], + if (runtimeMatch) { + const normalizedModelId = normalizeMajorMinorClaudeModelId( + runtimeMatch[1], + runtimeMatch[2], + runtimeMatch[3], trimmed.toLowerCase().includes("[1m]"), ); if (normalizedModelId) { @@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } } - const runtimeMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + const singleSegmentMatch = trimmed.match( + /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, ); - if (!runtimeMatch) { + if (!singleSegmentMatch) { return null; } - return normalizeMajorMinorClaudeModelId( - runtimeMatch[1], - runtimeMatch[2], - runtimeMatch[3], + return normalizeSingleSegmentClaudeModelId( + singleSegmentMatch[1], + singleSegmentMatch[2], trimmed.toLowerCase().includes("[1m]"), ); } diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 9e3cc088bd..a497826f73 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise claudeCodeVersion, @@ -50,6 +50,7 @@ describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-fable-5", @@ -72,7 +73,7 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); const defaults = models.filter((m) => m.isDefault); expect(defaults).toHaveLength(1); - expect(defaults[0].id).toBe("claude-opus-5"); + expect(defaults[0].id).toBe("claude-opus-5-5"); }); it("defines context window sizes in the catalog", () => { @@ -82,6 +83,7 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ + ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], @@ -109,6 +111,11 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5"); expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5"); + + expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5"); + expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5"); + expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5"); + expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); }); it("derives thinking options from model effort capabilities", () => { @@ -169,6 +176,8 @@ describe("getClaudeModels", () => { }); it.each([ + ["claude-opus-5-5", false, "medium"], + ["claude-opus-5-5-20260401", false, "medium"], ["claude-opus-5", true, "high"], ["claude-opus-5-20260724", true, "high"], ["claude-sonnet-5", true, "high"], @@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => { "claude-opus-4-8", ); }); + + it("does not collapse a prefixed minor release onto the major it extends", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe( + "claude-opus-5-5", + ); + expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe( + "claude-fable-5-1", + ); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe( + "claude-opus-5", + ); + }); }); describe("parseClaudeCodeVersion", () => { @@ -438,7 +460,7 @@ describe("findClaudeModel", () => { describe("Claude Opus 5 catalog", () => { it("offers a single Opus 5 entry with a 1M context window", () => { const opus5Models = getClaudeModels() - .filter((model) => model.id.startsWith("claude-opus-5")) + .filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id)) .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); expect(opus5Models).toEqual([ @@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => { }); }); +describe("Claude Opus 5.5 catalog", () => { + it("offers one Opus 5.5 entry with a 1M context window", () => { + const opus55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-opus-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(opus55Models).toEqual([ + { id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => { + const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5"); + + expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(opus55?.defaultThinkingOptionId).toBe("medium"); + expect( + opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => { + expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5"); + expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5"); + expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5"); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); @@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => { }); it("supports fast mode on Opus 5 but not on other Claude 5 models", () => { + expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true); expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true); expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false); expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false); From d22737c77af2805693ecc19f7bb6d49eb3a00ca5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 22 Sep 2026 20:30:04 +0200 Subject: [PATCH 02/34] chore: keep heap snapshots out of git The daemon dumps a heap snapshot next to the server package when it nears its heap limit. Those files are multi-gigabyte debugging artifacts we want to keep on disk, not stage. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5298013fdc..53a2169c67 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts packages/desktop/.cache/ packages/desktop/src-tauri/resources/managed-runtime/ app.json + +# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging. +*.heapsnapshot From eb665a55a8e25ed21edbc8230f983d8772c98d21 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 22 Sep 2026 20:30:04 +0200 Subject: [PATCH 03/34] docs(changelog): add 0.9.1 entry --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c120f91db..3c230b4933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.9.1 - 2026-09-22 + +### Added + +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri)) + +### Fixed + +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu)) +- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200)) + ## 0.9.0 - 2026-09-22 ### Added From 81865852011df86aa0ad0ae411cb2f5e4078153f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 22 Sep 2026 20:34:13 +0200 Subject: [PATCH 04/34] chore(release): cut 0.9.1 --- .../android/en-US/changelogs/90011.txt | 8 +++ .../android/en-US/changelogs/90012.txt | 8 +++ .../android/en-US/changelogs/90013.txt | 8 +++ .../android/en-US/changelogs/90014.txt | 8 +++ package-lock.json | 54 +++++++++---------- package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 +-- packages/client/package.json | 6 +-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/plugin/package.json | 10 ++-- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 12 ++--- packages/website/package.json | 2 +- 17 files changed, 85 insertions(+), 53 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/90011.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/90012.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/90013.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/90014.txt diff --git a/fastlane/metadata/android/en-US/changelogs/90011.txt b/fastlane/metadata/android/en-US/changelogs/90011.txt new file mode 100644 index 0000000000..c998686eb5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/90011.txt @@ -0,0 +1,8 @@ +Added +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer + +Fixed +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon +- Fixed Import session offering only the newest 100 Codex conversations + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/90012.txt b/fastlane/metadata/android/en-US/changelogs/90012.txt new file mode 100644 index 0000000000..c998686eb5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/90012.txt @@ -0,0 +1,8 @@ +Added +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer + +Fixed +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon +- Fixed Import session offering only the newest 100 Codex conversations + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/90013.txt b/fastlane/metadata/android/en-US/changelogs/90013.txt new file mode 100644 index 0000000000..c998686eb5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/90013.txt @@ -0,0 +1,8 @@ +Added +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer + +Fixed +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon +- Fixed Import session offering only the newest 100 Codex conversations + +Full notes: https://paseo.sh/changelog diff --git a/fastlane/metadata/android/en-US/changelogs/90014.txt b/fastlane/metadata/android/en-US/changelogs/90014.txt new file mode 100644 index 0000000000..c998686eb5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/90014.txt @@ -0,0 +1,8 @@ +Added +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer + +Fixed +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon +- Fixed Import session offering only the newest 100 Codex conversations + +Full notes: https://paseo.sh/changelog diff --git a/package-lock.json b/package-lock.json index 981d18221e..822e143895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.9.0", + "version": "0.9.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -36196,7 +36196,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -37262,12 +37262,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.9.0", - "@getpaseo/protocol": "0.9.0", - "@getpaseo/server": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/protocol": "0.9.1", + "@getpaseo/server": "0.9.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -37514,10 +37514,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { - "@getpaseo/protocol": "0.9.0", - "@getpaseo/relay": "0.9.0", + "@getpaseo/protocol": "0.9.1", + "@getpaseo/relay": "0.9.1", "zod": "^4.4.3" }, "devDependencies": { @@ -37528,7 +37528,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.9.0", + "version": "0.9.1", "license": "Apache-2.0", "dependencies": { "@getpaseo/cli": "*", @@ -37772,7 +37772,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.9.0", + "version": "0.9.1", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -38668,7 +38668,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -38697,14 +38697,14 @@ }, "packages/plugin": { "name": "@getpaseo/plugin", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.9.0", - "@getpaseo/protocol": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/protocol": "0.9.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -38714,8 +38714,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.9.0", - "@getpaseo/protocol": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/protocol": "0.9.1", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" @@ -38737,7 +38737,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "semver": "^7.8.5", "zod": "^4.4.3" @@ -38764,7 +38764,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38982,16 +38982,16 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.9.0", - "@getpaseo/highlight": "0.9.0", - "@getpaseo/plugin": "0.9.0", - "@getpaseo/protocol": "0.9.0", - "@getpaseo/relay": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/highlight": "0.9.1", + "@getpaseo/plugin": "0.9.1", + "@getpaseo/protocol": "0.9.1", + "@getpaseo/relay": "0.9.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", @@ -39895,7 +39895,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index 155fe6a886..059910bfe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.9.0", + "version": "0.9.1", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 1a0c25b3f4..b2b68fb183 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.9.0", + "version": "0.9.1", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index af539e2219..695068369a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.9.0", + "version": "0.9.1", "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.9.0", - "@getpaseo/protocol": "0.9.0", - "@getpaseo/server": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/protocol": "0.9.1", + "@getpaseo/server": "0.9.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 2d723451b3..761e5a0b74 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.9.0", + "version": "0.9.1", "description": "Paseo client SDK package", "files": [ "dist", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.9.0", - "@getpaseo/relay": "0.9.0", + "@getpaseo/protocol": "0.9.1", + "@getpaseo/relay": "0.9.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 384a4ac4e4..25fbb6db04 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.9.0", + "version": "0.9.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 4a9a48acbf..ed4aa28cbb 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.9.0", + "version": "0.9.1", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index c8c2199729..fba20d38ad 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.9.0", + "version": "0.9.1", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 70adc65b6f..2e77211567 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.9.0", + "version": "0.9.1", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -65,8 +65,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.9.0", - "@getpaseo/protocol": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/protocol": "0.9.1", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", @@ -76,8 +76,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.9.0", - "@getpaseo/protocol": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/protocol": "0.9.1", "react": "~19.1.0", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 94e6248558..7c65b77358 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.9.0", + "version": "0.9.1", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index f4054303e8..cea4c88491 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.9.0", + "version": "0.9.1", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index a121e4640e..7c053cf7db 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.9.0", + "version": "0.9.1", "description": "Paseo backend server", "files": [ "dist/server", @@ -106,11 +106,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.9.0", - "@getpaseo/highlight": "0.9.0", - "@getpaseo/plugin": "0.9.0", - "@getpaseo/protocol": "0.9.0", - "@getpaseo/relay": "0.9.0", + "@getpaseo/client": "0.9.1", + "@getpaseo/highlight": "0.9.1", + "@getpaseo/plugin": "0.9.1", + "@getpaseo/protocol": "0.9.1", + "@getpaseo/relay": "0.9.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index f7a1754d00..ae406d5185 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.9.0", + "version": "0.9.1", "private": true, "type": "module", "scripts": { From bc5bc969c852936b114463114c05c0b3ef354459 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:44:27 +0000 Subject: [PATCH 05/34] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3b89866b4f..aeb982410d 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-3cqO8DpPVDjYaSh0q5EPJuMpCgl2OJ17uLGVGuk1Wwc= +sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc= From a3fc59e8182cdea54a5ef915e6d0ca3499732793 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:06:24 +0200 Subject: [PATCH 06/34] Keep a project in the sidebar when its folder name ends in a space (#5205) * fix(app): keep sidebar order keys that end in whitespace A project view key embeds the project's path, so a directory whose name ends in a space produces a key that ends in a space. The sidebar order store trimmed every key on write, so the stored key could never match the key the sidebar looks up. computeSidebarOrderUpdates saw the project as missing from the order on every pass, appended it, and the store trimmed it away again, setting a fresh array each time. The effect that applies those updates re-ran on every render until React gave up with error #185, and the app failed to load until the folder was renamed. Keep keys verbatim on the runtime setters, dropping only blank keys and duplicates. Migration of persisted state still trims, where a stray space is an artifact of an older format rather than part of the key. Fixes #4880 * test(app): give AsyncStorage a real in-memory stub instead of mocking it `docs/testing.md` bans `vi.mock`, and the test added here mocked `@react-native-async-storage/async-storage` only because the module has no Node implementation: its default export reaches for the native module, so every write from the persist middleware rejects and a test that touches a persisted store drowns in unhandled rejections. That is exactly the case `test-stubs/` exists for, reached through the resolve.alias in `vitest.config.ts` the same way react-native-unistyles, react-native-svg and expo-linking are. The stub is a real in-memory key-value store with the same contract, so a test can read back what a store wrote rather than inspect a recording double. `sidebar-view-store.test.ts` carried the same mock for the same reason and no longer needs it either. * test(app): give the AsyncStorage stub the multi-key calls the app uses `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and is handed the real AsyncStorage, so it calls `multiGet` and `multiRemove` during reconciliation and cleanup. The stub stopped at the single-key calls, which is fine until a test reaches the provider cache and then fails with `storage.multiGet is not a function`. `multiSet` comes along with them so the three multi-key calls behave as a set rather than leaving the next caller to notice the gap the same way. * test(app): cover a project folder name ending in a space end to end Adds a browser regression for #4880: the daemon accepts a directory whose name ends in a space, and the sidebar has to render its project row instead of tearing the app down with "Maximum update depth exceeded" on every load. * test(app): move the trailing-space spec setup behind a seeded-project helper docs/testing.md asks for E2E bodies that read like plain English with the setup in helpers, and for helpers that own the project record until cleanup. The spec did its directory creation, daemon seeding, and removal inline. seedProjectNamedWithTrailingSpace owns the directory and the daemon project and returns a cleanup, the same shape seedWorkspace returns, which cannot be used here because mkdtemp appends the random suffix and so cannot produce a name that ends in a space. The body is now goto, assert the row, assert no error screen. The row is still located by its sidebar-project-row test id, which is how every other sidebar spec and the shared sidebar helpers address a project row. --------- Co-authored-by: L4XB Co-authored-by: paseo-bot[bot] --- .../sidebar-project-name-whitespace.spec.ts | 77 +++++++++++++++++++ .../src/stores/sidebar-order-store.test.ts | 76 +++++++++++++++++- .../app/src/stores/sidebar-order-store.ts | 41 +++++++--- .../app/src/stores/sidebar-view-store.test.ts | 10 +-- packages/app/test-stubs/async-storage.ts | 45 +++++++++++ packages/app/vitest.config.ts | 6 ++ 6 files changed, 233 insertions(+), 22 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-project-name-whitespace.spec.ts create mode 100644 packages/app/test-stubs/async-storage.ts diff --git a/packages/app/e2e/browser/sidebar-project-name-whitespace.spec.ts b/packages/app/e2e/browser/sidebar-project-name-whitespace.spec.ts new file mode 100644 index 0000000000..3197cd060a --- /dev/null +++ b/packages/app/e2e/browser/sidebar-project-name-whitespace.spec.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Page } from "@playwright/test"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { connectSeedClient } from "../support/helpers/seed-client"; + +interface SeededProject { + projectKey: string; + cleanup(): Promise; +} + +/** + * Adds a project whose directory name ends in a space, which `seedWorkspace` + * cannot produce because `mkdtemp` appends the random suffix. The project is + * owned until `cleanup`, which removes the daemon record before the directory. + */ +async function seedProjectNamedWithTrailingSpace(): Promise { + const parentDirectory = await mkdtemp(path.join(tmpdir(), "paseo-e2e-trailing-space-")); + const directoryPath = path.join(parentDirectory, `Reklamation-${randomUUID().slice(0, 8)} `); + await mkdir(directoryPath); + await writeFile(path.join(directoryPath, "README.md"), "# Trailing space\n"); + + const client = await connectSeedClient(); + const removeParentDirectory = () => rm(parentDirectory, { recursive: true, force: true }); + try { + const added = await client.addProject(directoryPath); + expect(added.error).toBeNull(); + expect(added.project?.projectRootPath).toBe(directoryPath); + const project = added.project; + if (!project?.projectKey) { + throw new Error(`The daemon added ${directoryPath} without a project key`); + } + return { + projectKey: project.projectKey, + cleanup: async () => { + await client.removeProject(project.projectId).catch(() => undefined); + await client.close().catch(() => undefined); + await removeParentDirectory().catch(() => undefined); + }, + }; + } catch (error) { + await client.close().catch(() => undefined); + await removeParentDirectory().catch(() => undefined); + throw error; + } +} + +async function expectProjectRow(page: Page, projectKey: string): Promise { + const row = page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`); + await expect(row).toBeVisible({ timeout: 60_000 }); +} + +async function expectAppShellRendered(page: Page): Promise { + await expect(page.getByText("Paseo ran into a problem.")).toHaveCount(0); +} + +// A project view key carries the project's path, so a directory whose name ends +// in a space produces a key that ends in a space. The sidebar order store used +// to trim it on write, which left the reconcile effect writing a new order on +// every render until React tore the app down with "Maximum update depth +// exceeded" on every load (#4880). +test("a project whose folder name ends in a space renders in the sidebar", async ({ page }) => { + const project = await seedProjectNamedWithTrailingSpace(); + + try { + await gotoAppShell(page); + + await expectProjectRow(page, project.projectKey); + await expectAppShellRendered(page); + } finally { + await project.cleanup(); + } +}); diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index c58931ca69..a68ca206f9 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { migrateSidebarOrderState } from "./sidebar-order-store"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + computeSidebarOrderUpdates, + type SidebarProjectEntry, +} from "@/hooks/sidebar-workspaces-view-model"; +import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { it("prefixes legacy per-server workspace order with the source server id", () => { @@ -31,3 +35,71 @@ describe("migrateSidebarOrderState", () => { expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); }); }); + +// A directory whose name ends in a space produces a view key that ends in a +// space. Trimming it on write used to make the sidebar's reconcile effect loop +// forever, crashing the app with React error #185 (see #4880). +describe("sidebar order keys that end in whitespace", () => { + const PROJECT_KEY = "host:srv-1:/home/u/Reklamation "; + + beforeEach(() => { + useSidebarOrderStore.setState({ + projectOrder: [], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: {}, + }); + }); + + it("stores a project key exactly as given", () => { + useSidebarOrderStore.getState().setProjectOrder([PROJECT_KEY]); + + expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]); + }); + + it("scopes a workspace order under the project key the sidebar indexes by", () => { + useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]); + + // use-sidebar-workspaces-list reads workspaceOrderByProject by view key + // directly, so the scope has to be stored under that same key. + expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([ + "srv-1:main", + ]); + expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]); + }); + + it("settles the sidebar order reconcile instead of rewriting it forever", () => { + const projects: SidebarProjectEntry[] = [ + { + viewKey: PROJECT_KEY, + projectName: "Reklamation ", + projectKind: "non_git", + iconWorkingDir: "/home/u/Reklamation ", + hosts: [], + workspaces: [], + }, + ]; + const runPass = () => + computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: useSidebarOrderStore.getState().projectOrder, + getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key), + }); + + // First pass adds the newly visible project. + const first = runPass(); + expect(first.projectOrder).toEqual([PROJECT_KEY]); + useSidebarOrderStore.getState().setProjectOrder(first.projectOrder ?? []); + + // Second pass must find nothing left to do; otherwise the effect that + // applies these updates re-runs on every render. + expect(runPass().projectOrder).toBeNull(); + }); + + it("still trims keys when migrating persisted state", () => { + const migrated = migrateSidebarOrderState({ + projectOrder: [" host-a:one ", "host-a:one"], + }); + + expect(migrated.projectOrder).toEqual(["host-a:one"]); + }); +}); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 27e7a190c8..10831480f8 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -38,6 +38,8 @@ interface SidebarWorkspaceOrderScope { projectViewKey: string; } +// Trims each key. Only for persisted state read at migration time, where a +// stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { const seen = new Set(); const normalized: string[] = []; @@ -54,6 +56,28 @@ function normalizeKeys(keys: string[]): string[] { return normalized; } +/** + * Drops blank keys and duplicates but keeps each key exactly as given. View + * keys embed a project's path, so a directory whose name ends in a space + * produces a key that ends in a space. Trimming it stores a key that can never + * match the one the sidebar looks up, so the caller sees its key as missing, + * writes it again, and the effect that reconciles the order never settles. + */ +function dedupeKeys(keys: string[]): string[] { + const seen = new Set(); + const deduped: string[] = []; + + for (const key of keys) { + if (!key.trim() || seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(key); + } + + return deduped; +} + function normalizeWorkspaceOrderByProject( workspaceOrderByProject: Record | undefined, ): Record { @@ -134,27 +158,22 @@ export const useSidebarOrderStore = create()( workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { - const normalized = normalizeKeys(keys); - set({ projectOrder: normalized }); + set({ projectOrder: dedupeKeys(keys) }); }, getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder, setPinnedWorkspaceOrder: (keys) => { - const normalized = normalizeKeys(keys); - set({ pinnedWorkspaceOrder: normalized }); + set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, getWorkspaceOrder: (projectViewKey) => { - const scope = projectViewKey.trim(); - if (!scope) return []; - return get().workspaceOrderByProject[scope] ?? []; + if (!projectViewKey.trim()) return []; + return get().workspaceOrderByProject[projectViewKey] ?? []; }, setWorkspaceOrder: (projectViewKey, keys) => { - const scope = projectViewKey.trim(); - if (!scope) return; - const normalized = normalizeKeys(keys); + if (!projectViewKey.trim()) return; set((state) => ({ workspaceOrderByProject: { ...state.workspaceOrderByProject, - [scope]: normalized, + [projectViewKey]: dedupeKeys(keys), }, })); }, diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index b9df715fa2..902294dd2d 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; import { createSidebarViewStorage, @@ -8,14 +8,6 @@ import { useSidebarViewStore, } from "./sidebar-view-store"; -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn().mockResolvedValue(null), - setItem: vi.fn().mockResolvedValue(undefined), - removeItem: vi.fn().mockResolvedValue(undefined), - }, -})); - interface MemoryStorage extends StateStorage> { reads: string[]; } diff --git a/packages/app/test-stubs/async-storage.ts b/packages/app/test-stubs/async-storage.ts new file mode 100644 index 0000000000..68cc5dd114 --- /dev/null +++ b/packages/app/test-stubs/async-storage.ts @@ -0,0 +1,45 @@ +/** + * AsyncStorage has no implementation under Node: its default export reaches for + * the native module and every call rejects, so any test that touches a + * persisted store emits unhandled rejections from the persist middleware. + * + * This is a real in-memory key-value store with the same contract, not a + * recording double, so a test can read back what a store wrote. + */ +const entries = new Map(); + +export function clearAsyncStorageStub(): void { + entries.clear(); +} + +const AsyncStorageStub = { + getItem: (key: string): Promise => Promise.resolve(entries.get(key) ?? null), + setItem: (key: string, value: string): Promise => { + entries.set(key, value); + return Promise.resolve(); + }, + removeItem: (key: string): Promise => { + entries.delete(key); + return Promise.resolve(); + }, + getAllKeys: (): Promise => Promise.resolve([...entries.keys()]), + // `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and + // hands it the real AsyncStorage, so a stub that stops at the single-key calls + // fails any test that reaches the provider cache with `multiGet is not a function`. + multiGet: (keys: readonly string[]): Promise> => + Promise.resolve(keys.map((key) => [key, entries.get(key) ?? null] as [string, string | null])), + multiSet: (pairs: ReadonlyArray): Promise => { + for (const [key, value] of pairs) entries.set(key, value); + return Promise.resolve(); + }, + multiRemove: (keys: readonly string[]): Promise => { + for (const key of keys) entries.delete(key); + return Promise.resolve(); + }, + clear: (): Promise => { + entries.clear(); + return Promise.resolve(); + }, +}; + +export default AsyncStorageStub; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 5e560be880..521fb6dbe0 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -185,6 +185,12 @@ export default defineConfig({ find: /^expo-linking$/, replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"), }, + // No Node implementation: every call on the real module rejects, so a test + // touching a persisted store drowns in unhandled rejections. + { + find: /^@react-native-async-storage\/async-storage$/, + replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"), + }, { find: /^lucide-react-native$/, replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"), From 3dbbbb535eca4290c59391b3513c4924dd7d91da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 22 Sep 2026 22:54:11 +0200 Subject: [PATCH 07/34] Sponsor page rework with paid homepage sponsor spots (#5219) * feat(website): rework sponsorship with homepage sponsor spots The founder note now states how the project is funded (sponsorship and Paseo Hub) instead of describing the product, and the same note and backing links render on the homepage and /sponsor from one module so the two cannot drift. Companies can sponsor one of four homepage logo spots at $500 a month through Stripe rather than GitHub Sponsors, because they need invoices and a vendor relationship. The spots render as placeholders until sold; approving a sponsor is an entry in src/data/sponsors.ts. The checkout URL is a placeholder until the Stripe Payment Link exists. * feat(website): point sponsor spot checkout at the Stripe payment link * docs(readme): add a sponsors section * docs(readme): add the sponsors section to the translated READMEs * docs(website): document the sponsor approval and Stripe cap workflow --- README.ja.md | 6 + README.ko.md | 6 + README.md | 6 + README.zh-CN.md | 6 + .../website/src/components/brand-icons.tsx | 22 ++ .../website/src/components/landing-page.tsx | 46 +-- .../website/src/components/sponsorship.tsx | 290 ++++++++++++++++++ packages/website/src/data/sponsors.ts | 42 +++ packages/website/src/routes/sponsor.tsx | 119 +------ 9 files changed, 386 insertions(+), 157 deletions(-) create mode 100644 packages/website/src/components/sponsorship.tsx create mode 100644 packages/website/src/data/sponsors.ts diff --git a/README.ja.md b/README.ja.md index 6629ccc397..335c87f6f4 100644 --- a/README.ja.md +++ b/README.ja.md @@ -151,6 +151,12 @@ npm run build:server npm run typecheck ``` +## スポンサー + +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援するか、月 500 ドルで[スポンサー枠を購入](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)して、貴社のロゴをここと [paseo.sh のホームページ](https://paseo.sh/sponsor#spot)に掲載できます。 + + + ## 関連プロジェクト - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー diff --git a/README.ko.md b/README.ko.md index 66f74f45fa..2bb1a15fc6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -161,6 +161,12 @@ npm run build:server npm run typecheck ``` +## 스폰서 + +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원하거나, 월 500달러에 [스폰서 자리를 구매](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00)하여 회사 로고를 이곳과 [paseo.sh 홈페이지](https://paseo.sh/sponsor#spot)에 게재할 수 있습니다. + + + ## 관련 프로젝트 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이 diff --git a/README.md b/README.md index a7a6c107e4..3a83f7e235 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,12 @@ npm run build:server npm run typecheck ``` +## Sponsors + +Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra), or [sponsor a spot](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00) for $500 a month to put your company's logo here and on the [paseo.sh homepage](https://paseo.sh/sponsor#spot). + + + ## Related projects - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir diff --git a/README.zh-CN.md b/README.zh-CN.md index c9c643b48f..6a5bde16f2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -151,6 +151,12 @@ npm run build:server npm run typecheck ``` +## 赞助 + +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作,或者以每月 500 美元[赞助一个展示位](https://buy.stripe.com/8x24gBczR7LNaokcve2sM00),把贵公司的 logo 放在这里和 [paseo.sh 首页](https://paseo.sh/sponsor#spot)上。 + + + ## 相关项目 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写 diff --git a/packages/website/src/components/brand-icons.tsx b/packages/website/src/components/brand-icons.tsx index 0b48a9d5fe..c7692b43d6 100644 --- a/packages/website/src/components/brand-icons.tsx +++ b/packages/website/src/components/brand-icons.tsx @@ -44,3 +44,25 @@ export function SlackIcon(props: React.SVGProps) { ); } + +export function OpenCollectiveIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function BuyMeACoffeeIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 6655c6ab37..248fa58675 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -78,6 +78,7 @@ import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons"; import { ClaudeIcon, MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup"; import { FAQItem } from "~/components/faq-item"; import { SiteFooter } from "~/components/site-footer"; +import { SponsorSection, SponsorsSection } from "~/components/sponsorship"; import { SiteHeader } from "~/components/site-header"; import "~/styles.css"; @@ -123,7 +124,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { - + + @@ -1196,45 +1198,3 @@ function FAQ() { ); } - -function SponsorCTA() { - return ( - -
-

Paseo is an independent open source project for running coding agents.

-

Its guiding principle is optionality and freedom of choice.

-

- I wanted to use any provider without being locked into any ecosystem, run it on my own - infrastructure, access it from anywhere, and have it be fully automatable. -

-

I am hoping that you will enjoy Paseo as much as I do.

-

If you like Paseo, sponsorship is the best way to support continued development.

-

- Mo

-
- -
- ); -} diff --git a/packages/website/src/components/sponsorship.tsx b/packages/website/src/components/sponsorship.tsx new file mode 100644 index 0000000000..958208e56f --- /dev/null +++ b/packages/website/src/components/sponsorship.tsx @@ -0,0 +1,290 @@ +import { ArrowRight, Check, ExternalLink } from "lucide-react"; +import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons"; +import { + BUY_ME_A_COFFEE_URL, + GITHUB_SPONSORS_URL, + HOMEPAGE_SPONSORS, + HOMEPAGE_SPOT_PRICE, + OPEN_COLLECTIVE_URL, + SPONSOR_CONTACT_EMAIL, + SPONSOR_SPOT_CHECKOUT_URL, + openSpotCount, +} from "~/data/sponsors"; + +function SectionHeading({ + as: Heading, + title, + description, + badge, +}: { + as: "h1" | "h2"; + title: string; + description?: string; + badge?: string; +}) { + return ( +
+
+ {title} + {badge ? ( + + {badge} + + ) : null} +
+ {description ? ( +

{description}

+ ) : null} +
+ ); +} + +const MAINTAINER_LINK = ( + + Mo + +); + +export function FounderNote() { + return ( +
+

+ I build Paseo on my own. There are no investors, no board and no company behind it, and I + have turned down funding offers to keep it that way. +

+

+ A tool that sits between you and your code, your keys and your machines has to stay neutral, + and that gets hard once someone else owns a return on it. Funding creates pressure to + monetize, and that pressure changes what gets built. +

+

+ Paseo is self-funded: the work is paid for by sponsorship and by{" "} + + Paseo Hub + + , an optional hosted service. Your support is what lets me work on Paseo full time. +

+

{MAINTAINER_LINK}, maintainer

+
+ ); +} + +interface BackingOption { + href: string; + name: string; + detail: string; + icon: React.ComponentType<{ className?: string }>; + external: boolean; + primary?: boolean; +} + +const BACKING_OPTIONS: ReadonlyArray = [ + { + href: GITHUB_SPONSORS_URL, + name: "GitHub Sponsors", + detail: "From $5 a month, or one time", + icon: GitHubIcon, + external: true, + primary: true, + }, + { + href: OPEN_COLLECTIVE_URL, + name: "Open Collective", + detail: "Monthly or one time, with an invoice", + icon: OpenCollectiveIcon, + external: true, + }, + { + href: BUY_ME_A_COFFEE_URL, + name: "Buy Me a Coffee", + detail: "One time, no account needed", + icon: BuyMeACoffeeIcon, + external: true, + }, + { + href: "/sponsor#spot", + name: "Sponsor a spot", + detail: "For companies", + icon: SpotIcon, + external: false, + }, +]; + +function SpotIcon({ className }: { className?: string }) { + return ( +