From dfddda7969016acbe603e41a8a15fccd7158d2bb Mon Sep 17 00:00:00 2001 From: Fabian Fischer Date: Fri, 5 Jun 2026 14:19:05 +0200 Subject: [PATCH 1/6] feat(server): make provider refresh timeout configurable via PASEO_PROVIDER_REFRESH_TIMEOUT_MS (#1346) * feat(server): make provider refresh timeout configurable Provider refresh probes (isAvailable + listModels/listModes) were hardcoded to a 30s timeout. With many providers and MCP servers configured, cold starts of agents like Copilot can exceed this on the first probe, surfacing 'Timed out refreshing Copilot after 30000ms' even though a manual retry succeeds (warm caches). Add a PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var so operators can bump the ceiling without rebuilding. The explicit constructor option still wins over the env var, and invalid values fall back to the 30s default. * refactor(server): address review feedback on refresh timeout config - Use Number() instead of Number.parseInt() so scientific notation like '6e4' parses as 60000 instead of being silently truncated to 6. - Use vi.stubEnv()/vi.unstubAllEnvs() in tests to match the rest of the suite and avoid manual process.env save/restore. --- .../agent/provider-snapshot-manager.test.ts | 84 +++++++++++++++++++ .../server/agent/provider-snapshot-manager.ts | 21 ++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b28accd07..6939089b6 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -156,6 +156,90 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { + // never-resolving isAvailable forces the timeout path + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 1, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.provider).toBe("codex"); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 5, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + // explicit option (5) wins over env var (1) + expect(entry.error).toMatch(/after 5ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + test("listProviders returns an entry per registered provider", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 08a51c2c5..0acbc3fe8 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -29,6 +29,25 @@ import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 30_000; +const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; + +// Provider refresh probes can be slow on cold starts (e.g. Copilot's first +// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). +// Allow operators to bump the ceiling via env var without rebuilding. +function resolveRefreshTimeoutMs(option: number | undefined): number { + if (typeof option === "number" && Number.isFinite(option) && option > 0) { + return option; + } + const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; + if (fromEnv) { + // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. + const parsed = Number(fromEnv); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_REFRESH_TIMEOUT_MS; +} type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; @@ -124,7 +143,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; - this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); this.providerRegistry = this.buildRegistry(); this.providerClients = { ...this.extraClients } as Record; } From 350bc08fc423b8561ae3492f3b6431d538e50bbb Mon Sep 17 00:00:00 2001 From: Kevin Aspesi Date: Fri, 5 Jun 2026 08:43:09 -0400 Subject: [PATCH 2/6] fix(app): make markdown links tappable on iOS (#1334) * fix(app): make markdown links tappable on iOS A markdown link's label renders as nested React elements (link > textgroup > text -> MarkdownInheritedText spans). The link's onPress was placed on the wrapping UITextView span, but react-native-uitextview only attaches onPress to the *string* children it converts into tappable RNUITextViewChild nodes - element children pass through untouched, so the handler never reached a node the native tap recognizer could fire. Link text stayed visible and selectable but dead to taps; web links never opened. Thread the link's press handler down to the leaf text spans via a new AssistantLinkPressProvider so it lands on the real tappable string nodes. MarkdownInheritedText consumes the context and forwards onPress to its MarkdownTextSpan; null outside a link, so ordinary text is unaffected. Gated to iOS - Android forwards onPress through nested already, and web uses the path. * refactor(app): export useAssistantLinkPress from module index Import it through the assistant-file-links public surface instead of reaching into the internal link-press-context file, matching the rest of message.tsx. Addresses Greptile review feedback on #1334. --------- Co-authored-by: Kevin Aspesi --- .../app/src/assistant-file-links/index.ts | 1 + .../link-press-context.ts | 24 +++++++++++ .../app/src/assistant-file-links/link.tsx | 41 ++++++++++++++----- .../app/src/components/markdown-text.ios.tsx | 9 ++-- packages/app/src/components/message.tsx | 14 ++++++- 5 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/assistant-file-links/link-press-context.ts diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 0a0e19207..723945409 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -3,6 +3,7 @@ export { AssistantMarkdownCodeLink, AssistantMarkdownLink, } from "./link"; +export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context"; export { classifyAssistantFileLink, normalizeInlinePathTarget, diff --git a/packages/app/src/assistant-file-links/link-press-context.ts b/packages/app/src/assistant-file-links/link-press-context.ts new file mode 100644 index 000000000..2d6ae1963 --- /dev/null +++ b/packages/app/src/assistant-file-links/link-press-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import type { TextProps } from "react-native"; + +// Carries a link's press handler down to the leaf text spans that render its +// label. On iOS an assistant link is a nested UITextView span, and +// react-native-uitextview only attaches onPress to the *string* children it +// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the +// MarkdownInheritedText spans markdown produces for link text) pass through +// untouched, so an onPress placed on the wrapping span never reaches a tappable +// native node. Threading the handler through context lets each leaf span hand +// onPress to its own string children, where the native tap recognizer can find +// it. Provided only on iOS (Android/web links tap fine via their own paths). +export interface AssistantLinkPress { + onPress: () => void; + accessibilityRole?: TextProps["accessibilityRole"]; +} + +const AssistantLinkPressContext = createContext(null); + +export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider; + +export function useAssistantLinkPress(): AssistantLinkPress | null { + return useContext(AssistantLinkPressContext); +} diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 4c19322a6..d209411ac 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; import { + Platform, Pressable, Text, View, @@ -10,6 +11,7 @@ import { import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; import { MarkdownTextSpan } from "@/components/markdown-text"; +import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -56,23 +58,42 @@ export function AssistantMarkdownLink({ () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); + const linkPress = useMemo( + () => ({ onPress, accessibilityRole: "link" }), + [onPress], + ); if (isNative) { // Must be a MarkdownTextSpan, not a plain : on iOS the link renders // inside the paragraph's native UITextView, and a plain nested there // is not hoisted into a UITextViewChild, so its text is silently dropped - // (the link disappears). The span composes correctly and stays selectable; - // onPress is forwarded (reliable tap-to-open on iOS is tracked by #21). + // (the link disappears). The span composes correctly and stays selectable. + // + // Tap-to-open: react-native-uitextview only wires onPress onto the *string* + // children it turns into RNUITextViewChild nodes — the element children that + // markdown emits for link text pass through untouched, so an onPress placed + // here never reaches a tappable native node. We thread it down through + // AssistantLinkPressProvider so each leaf text span re-attaches it to its + // own string children, where the native tap recognizer can find it. iOS + // only: Android forwards onPress through nested already, and web uses + // the path below. + const span = ( + + {children} + + ); return ( - - {children} - + {Platform.OS === "ios" ? ( + {span} + ) : ( + span + )} ); } diff --git a/packages/app/src/components/markdown-text.ios.tsx b/packages/app/src/components/markdown-text.ios.tsx index 8b925cebd..b280240ee 100644 --- a/packages/app/src/components/markdown-text.ios.tsx +++ b/packages/app/src/components/markdown-text.ios.tsx @@ -8,10 +8,11 @@ interface MarkdownTextSpanProps { children: ReactNode; // Links route through this span too (see assistant-file-links/link.tsx). A // plain nested in the paragraph UITextView is dropped, so the link - // must be a UITextView span to be visible. onPress is forwarded best-effort: - // react-native-uitextview nulls onPress on the root native view, so reliable - // tap-to-open is still tracked by #21 — but visible+selectable text beats an - // invisible link. + // must be a UITextView span to be visible. onPress is wired onto the leaf + // string children here: react-native-uitextview attaches it to the + // RNUITextViewChild nodes it builds from string content, which the native tap + // recognizer dispatches to. The link's handler reaches these leaf spans via + // AssistantLinkPressProvider (see assistant-file-links/link-press-context). onPress?: TextProps["onPress"]; accessibilityRole?: TextProps["accessibilityRole"]; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index ce2c738f2..4d986a41d 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -100,6 +100,7 @@ import { AssistantMarkdownLink, type InlinePathTarget, useAssistantFileLinkActions, + useAssistantLinkPress, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -1532,8 +1533,19 @@ function MarkdownInheritedText({ () => [inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle], ); + // When this span renders link label text on iOS, pick up the link's press + // handler from context and hand it to MarkdownTextSpan, which forwards it to + // the leaf string children react-native-uitextview makes tappable. Null + // outside a link (and on every other platform, where no provider mounts), so + // ordinary text is unaffected. See assistant-file-links/link-press-context. + const linkPress = useAssistantLinkPress(); return ( - + {children} ); From 1377adbece752a97795f4001518ec4f7ce6aa5ff Mon Sep 17 00:00:00 2001 From: Matteo Pietro Dazzi Date: Fri, 5 Jun 2026 14:58:38 +0200 Subject: [PATCH 3/6] fix(claude): respect profile models for built-in claude provider (#1311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): respect profile models for built-in claude provider The built-in Claude provider was the only one in the registry whose profile `models` array was treated as additive on top of the hardcoded first-party catalog. Every other built-in provider (and every custom provider) replaces the runtime model list when `models` is set, which matches the documented behavior in docs/custom-providers.md. For users pointing Claude Code at a third-party Anthropic-compatible gateway (Z.AI, Alibaba/Qwen, MiniMax, custom proxies, …) and curating the picker with `agents.providers.claude.models`, the old behavior leaked the nine first-party Claude models into the dropdown, making it impossible to ship a curated list. This is issue #1299. The fix flips the built-in Claude entry in `provider-registry.ts` to match the other providers, so `models` replaces the runtime catalog (including the settings.json-discovered entries surfaced by getClaudeModelsWithSettings). The existing `additionalModels` field keeps its additive semantics for anyone who still wants to append entries on top of the first-party list. - provider-registry.ts: drop the claude-only `profileModelsAreAdditive` branch and align with the rest of the registry. - provider-registry.test.ts: update the "append to runtime models" expectation for built-in Claude to "replace runtime models" and add a regression test that mirrors the issue scenario (hardcoded catalog + configured profile models, expect only the profile models). - agent.test.ts: make the "returns hardcoded claude models" hermetic by pointing CLAUDE_CONFIG_DIR at an empty temp dir; the test was reading the host's real ~/.claude/settings.json (which now contains MiniMax env vars from the issue report) and leaking that into the assertion. - custom-providers.md: correct the note about Claude profile models being additive and document the new replace semantics alongside additionalModels. Closes #1299 * test(claude): drop explanatory comments from new tests * refactor(claude): inject configDir into ClaudeAgentClient for test hermeticity Greptile flagged the previous hermeticity fix on agent.test.ts: mutating process.env.CLAUDE_CONFIG_DIR inside a test leaks the redirection into any concurrent test in the same process for the duration of the try block. Thread a `configDir` option through ClaudeAgentClient -> getClaudeModelsWithSettings -> readClaudeSettingsModels -> resolveClaudeConfigDir instead. The constructor follows the same injection pattern as `resolveBinary`, so the test passes an empty temp dir via the option and no shared global state is touched. - models.ts: add an optional `configDir` to getClaudeModelsWithSettings, readClaudeSettingsModels, and resolveClaudeConfigDir. Env var remains the fallback for production callers. - agent.ts: add `configDir?` to ClaudeAgentClientOptions, store it on the instance, and forward it to getClaudeModelsWithSettings from listModels. - agent.test.ts: drop the process.env save/mutate/restore dance and pass `configDir: emptyConfigDir` to the constructor instead. Refs #1311 --- docs/custom-providers.md | 2 +- .../server/agent/provider-registry.test.ts | 35 +++++++++++--- .../src/server/agent/provider-registry.ts | 2 +- .../agent/providers/claude/agent.test.ts | 47 +++++++++++-------- .../server/agent/providers/claude/agent.ts | 5 +- .../server/agent/providers/claude/models.ts | 18 ++++--- 6 files changed, 75 insertions(+), 34 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 5ae98afb2..69ed91212 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -577,7 +577,7 @@ Each entry in the `models` array: The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. -This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated. +This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. ### Gotcha: `extends: "claude"` with third-party endpoints diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 9d8acc8ae..565ca6999 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -835,7 +835,7 @@ describe("model merging", () => { ]); }); - test("built-in Claude profile models append to runtime models", async () => { + test("built-in Claude profile models replace runtime models (issue #1299)", async () => { mockState.runtimeModels.set("claude", [ { provider: "claude", @@ -872,11 +872,6 @@ describe("model merging", () => { }); expect(models).toEqual([ - { - provider: "claude", - id: "runtime-model", - label: "Runtime Model", - }, { provider: "claude", id: "shared-model", @@ -1129,4 +1124,32 @@ describe("model merging", () => { const defaultModel = models.find((model) => model.isDefault) ?? models[0]; expect(defaultModel?.id).toBe("profile-default"); }); + + test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => { + mockState.runtimeModels.set("claude", [ + { provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true }, + { provider: "claude", id: "claude-opus-4-7", label: "Opus 4.7" }, + { provider: "claude", id: "claude-sonnet-4-6", label: "Sonnet 4.6" }, + { provider: "claude", id: "claude-haiku-4-5", label: "Haiku 4.5" }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { id: "MiniMax-M2.7", label: "MiniMax-M2.7" }, + { id: "MiniMax-M3", label: "MiniMax-M3", isDefault: true }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models.map((model) => model.id)).toEqual(["MiniMax-M2.7", "MiniMax-M3"]); + expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3"); + }); }); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index b474047fe..823ea6546 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -523,7 +523,7 @@ function buildResolvedBuiltinProviders( runtimeSettings: mergedRuntimeSettings, profileModels: override?.models ?? [], additionalModels: override?.additionalModels ?? [], - profileModelsAreAdditive: definition.id === "claude", + profileModelsAreAdditive: false, enabled: override?.enabled !== false, derivedFromProviderId: null, createBaseClient: (logger) => 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 be905bf9e..382885e8e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -397,28 +397,37 @@ describe("ClaudeAgentClient.listModels", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { - const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); - const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); + const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-")); + try { + const client = new ClaudeAgentClient({ + logger, + resolveBinary: async () => "/test/claude/bin", + configDir: emptyConfigDir, + }); + const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); - expect(models.map((m) => m.id)).toEqual([ - "claude-opus-4-8[1m]", - "claude-opus-4-8", - "claude-opus-4-7[1m]", - "claude-opus-4-7", - "claude-opus-4-6[1m]", - "claude-opus-4-6", - "claude-sonnet-4-6[1m]", - "claude-sonnet-4-6", - "claude-haiku-4-5", - ]); + expect(models.map((m) => m.id)).toEqual([ + "claude-opus-4-8[1m]", + "claude-opus-4-8", + "claude-opus-4-7[1m]", + "claude-opus-4-7", + "claude-opus-4-6[1m]", + "claude-opus-4-6", + "claude-sonnet-4-6[1m]", + "claude-sonnet-4-6", + "claude-haiku-4-5", + ]); - for (const model of models) { - expect(model.provider).toBe("claude"); - expect(model.label.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.provider).toBe("claude"); + expect(model.label.length).toBeGreaterThan(0); + } + + const defaultModel = models.find((m) => m.isDefault); + expect(defaultModel?.id).toBe("claude-opus-4-8"); + } finally { + await fs.rm(emptyConfigDir, { recursive: true, force: true }); } - - const defaultModel = models.find((m) => m.isDefault); - expect(defaultModel?.id).toBe("claude-opus-4-8"); }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b6473c699..60bbf0ee5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -279,6 +279,7 @@ interface ClaudeAgentClientOptions { runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; + configDir?: string; } interface ClaudeAgentSessionOptions { @@ -1277,6 +1278,7 @@ export class ClaudeAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; + private readonly configDir?: string; constructor(options: ClaudeAgentClientOptions) { this.defaults = options.defaults; @@ -1284,6 +1286,7 @@ export class ClaudeAgentClient implements AgentClient { this.runtimeSettings = options.runtimeSettings; this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings)); + this.configDir = options.configDir; } async createSession( @@ -1334,7 +1337,7 @@ export class ClaudeAgentClient implements AgentClient { async listModels(_options: ListModelsOptions): Promise { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. - return await getClaudeModelsWithSettings(this.logger); + return await getClaudeModelsWithSettings(this.logger, this.configDir); } async listFeatures(config: AgentSessionConfig): Promise { diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 644d26ff6..31c2cc77f 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -98,9 +98,12 @@ export function getClaudeModels(): AgentModelDefinition[] { return CLAUDE_MODELS.map((model) => ({ ...model })); } -export async function getClaudeModelsWithSettings(logger: Logger): Promise { +export async function getClaudeModelsWithSettings( + logger: Logger, + configDir?: string, +): Promise { const hardcodedModels = getClaudeModels(); - const settingsModels = await readClaudeSettingsModels(logger); + const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; } @@ -119,8 +122,11 @@ export async function getClaudeModelsWithSettings(logger: Logger): Promise { - const settingsPath = path.join(resolveClaudeConfigDir(), "settings.json"); +async function readClaudeSettingsModels( + logger: Logger, + configDir?: string, +): Promise { + const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json"); let parsed: unknown; try { @@ -155,8 +161,8 @@ async function readClaudeSettingsModels(logger: Logger): Promise Date: Fri, 5 Jun 2026 15:08:24 +0200 Subject: [PATCH 4/6] feat(desktop): support multiple windows Closes #250 --- docs/architecture.md | 6 + .../src/components/sidebar-workspace-list.tsx | 67 +++++++-- packages/app/src/desktop/host.ts | 1 + packages/desktop/.gitignore | 3 + packages/desktop/scripts/dev.ps1 | 52 ++++++- packages/desktop/scripts/dev.sh | 56 +++++++- packages/desktop/src/features/menu.ts | 26 +++- packages/desktop/src/main.ts | 127 ++++++++++++------ .../src/pending-open-project-store.test.ts | 32 +++++ .../desktop/src/pending-open-project-store.ts | 32 +++++ packages/desktop/src/preload.ts | 2 + packages/desktop/src/window/window-manager.ts | 20 +-- 12 files changed, 362 insertions(+), 62 deletions(-) create mode 100644 packages/desktop/src/pending-open-project-store.test.ts create mode 100644 packages/desktop/src/pending-open-project-store.ts diff --git a/docs/architecture.md b/docs/architecture.md index e628dbbaa..c16cafdec 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -132,6 +132,12 @@ Electron wrapper for macOS, Linux, and Windows. - Native file access for workspace integration - Same WebSocket client as mobile app +**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo ` launch. + +> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. +> +> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up. + ### `packages/website` — Marketing site TanStack Router + Cloudflare Workers. Serves paseo.sh. diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index f1fad57fc..40cc83cb5 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -128,7 +128,12 @@ import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, } from "@/workspace/workspace-archive"; -import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; +import { + isWeb as platformIsWeb, + isNative as platformIsNative, + getIsElectron, +} from "@/constants/platform"; +import { getDesktopHost } from "@/desktop/host"; const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; @@ -153,14 +158,24 @@ const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); const ThemedPencil = withUnistyles(Pencil); -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); -const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); -const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); -const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); -const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); +const redColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.red[500], +}); +const amberColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.amber[500], +}); +const greenColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.green[500], +}); +const purpleColorMapping = (theme: Theme) => ({ + color: theme.colors.palette.purple[500], +}); const syncedLoaderColorMapping = (theme: Theme) => ({ color: theme.colorScheme === "light" @@ -515,6 +530,7 @@ function ProjectRowTrailingActions({ > @@ -532,6 +548,9 @@ const markAsReadLeadingIcon = ( ); const archiveLeadingIcon = ; const renameLeadingIcon = ; +const openInNewWindowLeadingIcon = ( + +); function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( @@ -544,18 +563,35 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { function ProjectKebabMenu({ projectKey, + projectPath, onRemoveProject, removeProjectStatus, }: { projectKey: string; + projectPath: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { + const toast = useToast(); const handleOpenProjectSettings = useCallback(() => { if (projectKey.trim().length === 0) return; router.navigate(buildProjectSettingsRoute(projectKey)); }, [projectKey]); const canOpenProjectSettings = projectKey.trim().length > 0; + // Desktop-only: open a second window that lands on this project via the same + // open-project flow as a CLI launch. The project stays visible here too — no + // ownership, no move. + const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0; + const handleOpenInNewWindow = useCallback(() => { + const trimmedPath = projectPath.trim(); + if (trimmedPath.length === 0) return; + void getDesktopHost() + ?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath }) + ?.catch((error) => { + console.warn("[sidebar] openNew failed", error); + toast.error("Couldn't open a new window"); + }); + }, [projectPath, toast]); return ( ) : null} + {canOpenInNewWindow ? ( + + Open in new window + + ) : null} ; onWorkspacePress?: () => void; }) { - const hydratedWorkspaces = useStatusModeWorkspaceEntries({ serverId, projects }); + const hydratedWorkspaces = useStatusModeWorkspaceEntries({ + serverId, + projects, + }); const projectNamesByKey = useProjectNamesMap(serverId); const showShortcutBadges = useShowShortcutBadges(); @@ -2459,7 +2507,10 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects }); + const projectIconByProjectKey = useProjectIconDataByProjectKey({ + serverId, + projects, + }); useEffect(() => { const timeouts = creatingWorkspaceTimeoutsRef.current; diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index ade806290..f61909903 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -98,6 +98,7 @@ export interface DesktopWindowBridge { } export interface DesktopWindowModuleBridge { + openNew?: (options?: { pendingOpenProjectPath?: string | null }) => Promise; getCurrentWindow?: () => DesktopWindowBridge; } diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index ce59b2b70..e60b37123 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -7,3 +7,6 @@ node_modules/ # TypeScript *.tsbuildinfo + +# Isolated dev environment (PASEO_HOME + Electron userData) created by scripts/dev.sh +.dev/ diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 70db1044e..f7b07064d 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -49,12 +49,60 @@ $env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$R # the daemon binds to localhost and this script is never used for production. $env:PASEO_CORS_ORIGINS = "*" +# Fully isolate the dev instance from a production Paseo install so `npm run dev` +# works while the installed app is open. Without this the dev build loses the +# Electron single-instance lock to the installed app and quits, and ends up +# pointed at the production daemon, whose CORS allowlist rejects the Metro origin. +# PASEO_HOME defaults to a script-managed dev home. If you override it (to point +# dev at real data), we DON'T touch your config.json — only the managed home gets +# its daemon config seeded below, so we never rewrite a production config. +$DevStateDir = "$DesktopDir\.dev" +if (-not $env:PASEO_HOME) { + $env:PASEO_HOME = "$DevStateDir\paseo-home" + $PaseoHomeManaged = $true +} else { + $PaseoHomeManaged = $false +} +if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" } +New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null + +$DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" } +if (-not $env:PASEO_LISTEN) { $env:PASEO_LISTEN = "127.0.0.1:$DevDaemonPort" } + +# Seed the isolated daemon config. The desktop daemon-manager decides whether a +# daemon is already running by reading `daemon.listen` from this config.json +# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without +# this it reads the default 6767, finds a production daemon there, and connects +# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin +# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon. +# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME +# (that could clobber a production config.json with the dev port + wildcard CORS). +if ($PaseoHomeManaged) { + node -e ' +const fs = require("fs"); +const [path, port] = [process.argv[1], process.argv[2]]; +let cfg = {}; +try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {} +cfg.version = cfg.version || 1; +cfg.daemon = cfg.daemon || {}; +cfg.daemon.listen = `127.0.0.1:${port}`; +cfg.daemon.cors = cfg.daemon.cors || {}; +cfg.daemon.cors.allowedOrigins = ["*"]; +fs.writeFileSync(path, JSON.stringify(cfg, null, 2)); +' "$($env:PASEO_HOME)/config.json" $DevDaemonPort +} else { + Write-Host " (custom PASEO_HOME - leaving its config.json untouched)" +} + Write-Host @" ====================================================== Paseo Desktop Dev (Windows) ====================================================== - Metro: http://localhost:$($env:EXPO_PORT) - CDP: http://127.0.0.1:$RemoteDebuggingPort + Metro: http://localhost:$($env:EXPO_PORT) + CDP: http://127.0.0.1:$RemoteDebuggingPort + Daemon: $($env:PASEO_LISTEN) (isolated) + PASEO_HOME: $($env:PASEO_HOME) + userData: $($env:PASEO_ELECTRON_USER_DATA_DIR) ====================================================== "@ diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index efce05667..4a959d962 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -22,11 +22,63 @@ export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--re # and the daemon still binds to localhost. export PASEO_CORS_ORIGINS="*" +# Fully isolate the dev instance from a production Paseo install so `npm run dev` +# works while the installed app is open. Without this the dev build (a) loses the +# Electron single-instance lock to the installed app and quits, and (b) ends up +# pointed at the production daemon, whose CORS allowlist rejects the Metro origin. +# PASEO_HOME defaults to a script-managed dev home. If you override it (to point +# dev at real data), we DON'T touch your config.json — only the managed home gets +# its daemon config seeded below, so we never rewrite a production ~/.paseo config. +# - PASEO_ELECTRON_USER_DATA_DIR: a separate Electron profile → separate +# single-instance lock, so dev and the installed app coexist. +# - PASEO_LISTEN: a distinct port so the dev daemon never collides with prod's 6767. +DEV_STATE_DIR="$DESKTOP_DIR/.dev" +if [ -n "${PASEO_HOME:-}" ]; then + PASEO_HOME_MANAGED=0 +else + PASEO_HOME="$DEV_STATE_DIR/paseo-home" + PASEO_HOME_MANAGED=1 +fi +export PASEO_HOME +export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_STATE_DIR/user-data}" +mkdir -p "$PASEO_HOME" "$PASEO_ELECTRON_USER_DATA_DIR" + +DEV_DAEMON_PORT="${PASEO_DEV_DAEMON_PORT:-6788}" +export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:$DEV_DAEMON_PORT}" + +# Seed the isolated daemon config. The desktop daemon-manager decides whether a +# daemon is already running by reading `daemon.listen` from this config.json +# (it does NOT honor the PASEO_LISTEN env var) and probing that address. Without +# this it reads the default 6767, finds a production daemon there, and connects +# the dev app to prod — whose CORS allowlist then rejects the Metro origin. Pin +# the dev port + wildcard CORS in the file so the dev app starts its OWN daemon. +# ONLY seed the script-managed home: never rewrite a user-supplied PASEO_HOME +# (that could clobber a production config.json with the dev port + wildcard CORS). +if [ "$PASEO_HOME_MANAGED" = "1" ]; then + node -e ' +const fs = require("fs"); +const [path, port] = [process.argv[1], process.argv[2]]; +let cfg = {}; +try { cfg = JSON.parse(fs.readFileSync(path, "utf8")); } catch {} +cfg.version = cfg.version || 1; +cfg.daemon = cfg.daemon || {}; +cfg.daemon.listen = `127.0.0.1:${port}`; +cfg.daemon.cors = cfg.daemon.cors || {}; +cfg.daemon.cors.allowedOrigins = ["*"]; +fs.writeFileSync(path, JSON.stringify(cfg, null, 2)); +' "$PASEO_HOME/config.json" "$DEV_DAEMON_PORT" +else + echo " (custom PASEO_HOME — leaving its config.json untouched)" +fi + echo "══════════════════════════════════════════════════════" echo " Paseo Desktop Dev" echo "══════════════════════════════════════════════════════" -echo " Metro: http://localhost:${EXPO_PORT}" -echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}" +echo " Metro: http://localhost:${EXPO_PORT}" +echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}" +echo " Daemon: ${PASEO_LISTEN} (isolated)" +echo " PASEO_HOME: ${PASEO_HOME}" +echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}" echo "══════════════════════════════════════════════════════" # Launch Metro + Electron together, kill both on exit diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts index b89244482..94f29ac53 100644 --- a/packages/desktop/src/features/menu.ts +++ b/packages/desktop/src/features/menu.ts @@ -6,6 +6,10 @@ interface ShowContextMenuInput { hasSelection?: boolean; } +interface ApplicationMenuOptions { + onNewWindow: () => void; +} + function withBrowserWindow( callback: (win: BrowserWindow) => void, ): (_item: Electron.MenuItem, baseWin: Electron.BaseWindow | undefined) => void { @@ -41,10 +45,12 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac win.webContents.reload(); } -export function setupApplicationMenu(): void { +function buildApplicationMenuTemplate( + options: ApplicationMenuOptions, +): Electron.MenuItemConstructorOptions[] { const isMac = process.platform === "darwin"; - const template: Electron.MenuItemConstructorOptions[] = [ + return [ ...(isMac ? [ { @@ -63,6 +69,18 @@ export function setupApplicationMenu(): void { }, ] : []), + { + label: "File", + submenu: [ + { + label: "New Window", + accelerator: "CmdOrCtrl+Shift+N", + click: () => { + options.onNewWindow(); + }, + }, + ], + }, { label: "Edit", submenu: [ @@ -130,8 +148,10 @@ export function setupApplicationMenu(): void { ], }, ]; +} - const menu = Menu.buildFromTemplate(template); +export function setupApplicationMenu(options: ApplicationMenuOptions): void { + const menu = Menu.buildFromTemplate(buildApplicationMenuTemplate(options)); Menu.setApplicationMenu(menu); ipcMain.handle("paseo:menu:showContextMenu", (event, input?: ShowContextMenuInput) => { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 01e4883da..17721ee30 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -53,6 +53,7 @@ import { setWorkspaceActivePaseoBrowserId, } from "./features/browser-webviews.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; +import { PendingOpenProjectStore } from "./pending-open-project-store.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; import { clampWindowStateToWorkAreas, createWindowStateStore } from "./settings/window-state.js"; import { @@ -94,7 +95,6 @@ function preventUnsafeBrowserWebviewNavigation( event.preventDefault(); } } -const OPEN_PROJECT_EVENT = "paseo:event:open-project"; const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut"; const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key"; @@ -257,6 +257,12 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ isDefaultApp: process.defaultApp, }); +// Each window pulls its own pending open-project path on mount, keyed by +// webContents id, so deep-linked windows (second-instance launches, the +// in-app "Open in new window" action) land on the right project without +// racing a global. +const pendingOpenProjectStore = new PendingOpenProjectStore(); + if (PASEO_DEBUG) { log.info("[open-project] argv:", process.argv); log.info("[open-project] isDefaultApp:", process.defaultApp); @@ -265,10 +271,13 @@ if (PASEO_DEBUG) { // The renderer pulls the pending path on mount via IPC — this avoids // a race where the push event arrives before React registers its listener. -ipcMain.handle("paseo:get-pending-open-project", () => { - log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath); - const result = pendingOpenProjectPath; - pendingOpenProjectPath = null; +ipcMain.handle("paseo:get-pending-open-project", (event) => { + const webContentsId = event.sender.id; + const result = pendingOpenProjectStore.take(webContentsId); + log.info("[open-project] renderer requested pending path:", { + webContentsId, + pendingPath: result, + }); return result; }); @@ -326,7 +335,10 @@ ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknow }); protocol.registerSchemesAsPrivileged([ - { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, + { + scheme: APP_SCHEME, + privileges: { standard: true, secure: true, supportFetchAPI: true }, + }, ]); // --------------------------------------------------------------------------- @@ -395,12 +407,24 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] { return [primary, ...others].map((display) => display.workArea); } -async function createMainWindow(): Promise { +async function createWindow( + options: { + pendingOpenProjectPath?: string | null; + restoreWindowState?: boolean; + } = {}, +): Promise { const iconPath = getWindowIconPath(); const systemTheme = resolveSystemWindowTheme(); - const windowStateStore = createWindowStateStore({ userDataPath: app.getPath("userData") }); - const savedWindowState = await windowStateStore.load(); + // Only the first window of a session restores and persists saved geometry. + // Additional windows (⌘N, second-instance, "Open in new window") open at the + // default size and let the OS cascade them, so they neither stack on top of + // the restored window nor fight over the single window-state store. + const restoreWindowState = options.restoreWindowState ?? false; + const windowStateStore = restoreWindowState + ? createWindowStateStore({ userDataPath: app.getPath("userData") }) + : null; + const savedWindowState = windowStateStore ? await windowStateStore.load() : null; const restoredWindowState = savedWindowState ? clampWindowStateToWorkAreas(savedWindowState, getWorkAreasPrimaryFirst()) : null; @@ -424,6 +448,12 @@ async function createMainWindow(): Promise { }, }); + const webContentsId = mainWindow.webContents.id; + pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath); + mainWindow.on("closed", () => { + pendingOpenProjectStore.delete(webContentsId); + }); + if (devWorktreeName) { app.dock?.setBadge(devWorktreeName); } @@ -434,7 +464,9 @@ async function createMainWindow(): Promise { setupDarwinCompositorWatchdog(mainWindow); setupWindowResizeEvents(mainWindow); - setupWindowStatePersistence(mainWindow, windowStateStore); + if (windowStateStore) { + setupWindowStatePersistence(mainWindow, windowStateStore); + } setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => { @@ -531,31 +563,27 @@ async function createMainWindow(): Promise { const { loadReactDevTools } = await import("./features/react-devtools.js"); await loadReactDevTools(); await mainWindow.loadURL(DEV_SERVER_URL); - return; + return mainWindow; } await mainWindow.loadURL(`${APP_SCHEME}://app/`); -} - -function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void { - const send = () => { - log.info("[open-project] sending event to renderer:", projectPath); - win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath }); - }; - - if (win.webContents.isLoadingMainFrame()) { - log.info("[open-project] waiting for did-finish-load before sending event"); - win.webContents.once("did-finish-load", send); - return; - } - - send(); + return mainWindow; } // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- +// Resolves once bootstrap() has registered the custom protocol handler and IPC +// handlers and created the first window. second-instance window creation waits +// on this rather than app.whenReady(): in packaged mode createWindow loads +// `paseo://app/`, which fails if the protocol handler isn't registered yet, and +// a second instance can arrive mid-cold-start. +let resolveBootstrapComplete: () => void; +const bootstrapComplete = new Promise((resolve) => { + resolveBootstrapComplete = resolve; +}); + function setupSingleInstanceLock(): boolean { if (DISABLE_SINGLE_INSTANCE_LOCK) { log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK"); @@ -575,15 +603,14 @@ function setupSingleInstanceLock(): boolean { isDefaultApp: false, }); log.info("[open-project] second-instance openProjectPath:", openProjectPath); - const win = BrowserWindow.getAllWindows()[0]; - if (win) { - win.show(); - if (win.isMinimized()) win.restore(); - win.focus(); - if (openProjectPath) { - sendOpenProjectEvent(win, openProjectPath); - } - } + // Relaunching the app (CLI `paseo [path]`, double-click, etc.) opens a new + // window rather than focusing the existing one. Wait for bootstrap (not just + // app.whenReady) so the protocol + IPC handlers exist before the window loads. + void bootstrapComplete + .then(() => createWindow({ pendingOpenProjectPath: openProjectPath })) + .catch((error) => { + log.error("[window] failed to create window from second-instance", error); + }); }); return true; @@ -689,7 +716,13 @@ async function bootstrap(): Promise { }); applyAppIcon(); - setupApplicationMenu(); + setupApplicationMenu({ + onNewWindow: () => { + void createWindow().catch((error) => { + log.error("[window] failed to create window from menu", error); + }); + }, + }); ensureNotificationCenterRegistration(); if (await runDesktopSmokeIfRequested()) { return; @@ -701,11 +734,29 @@ async function bootstrap(): Promise { registerOpenerHandlers(); registerEditorTargetHandlers(); - await createMainWindow(); + // In-app "Open in new window": opens a window that lands on the given project + // via the same open-project flow as a CLI launch (no move, no ownership). + ipcMain.handle("paseo:window:openNew", async (_event, options?: unknown) => { + const pendingPath = + options && typeof options === "object" && "pendingOpenProjectPath" in options + ? (options as { pendingOpenProjectPath?: unknown }).pendingOpenProjectPath + : null; + await createWindow({ + pendingOpenProjectPath: typeof pendingPath === "string" ? pendingPath : null, + }); + }); + + // The first window of the session restores and persists saved geometry. + await createWindow({ pendingOpenProjectPath, restoreWindowState: true }); + pendingOpenProjectPath = null; + + // Protocol + IPC handlers and the first window now exist: release any + // second-instance launches that arrived during cold start. + resolveBootstrapComplete(); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - await createMainWindow(); + await createWindow({ restoreWindowState: true }); } }); } diff --git a/packages/desktop/src/pending-open-project-store.test.ts b/packages/desktop/src/pending-open-project-store.test.ts new file mode 100644 index 000000000..448e15bf1 --- /dev/null +++ b/packages/desktop/src/pending-open-project-store.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { PendingOpenProjectStore } from "./pending-open-project-store"; + +describe("PendingOpenProjectStore", () => { + it("stores pending paths per window and consumes them independently", () => { + const store = new PendingOpenProjectStore(); + + store.set(101, "/tmp/project-a"); + store.set(202, "/tmp/project-b"); + + expect(store.take(101)).toBe("/tmp/project-a"); + expect(store.take(202)).toBe("/tmp/project-b"); + }); + + it("clears a pending path after it is consumed", () => { + const store = new PendingOpenProjectStore(); + + store.set(101, "/tmp/project-a"); + + expect(store.take(101)).toBe("/tmp/project-a"); + expect(store.take(101)).toBeNull(); + }); + + it("ignores empty and whitespace-only paths", () => { + const store = new PendingOpenProjectStore(); + + store.set(101, " "); + + expect(store.take(101)).toBeNull(); + }); +}); diff --git a/packages/desktop/src/pending-open-project-store.ts b/packages/desktop/src/pending-open-project-store.ts new file mode 100644 index 000000000..27543e88c --- /dev/null +++ b/packages/desktop/src/pending-open-project-store.ts @@ -0,0 +1,32 @@ +export class PendingOpenProjectStore { + private readonly pendingPathByWebContentsId = new Map(); + + set(webContentsId: number, projectPath: string | null | undefined): void { + const normalizedPath = this.normalizeProjectPath(projectPath); + if (!normalizedPath) { + this.pendingPathByWebContentsId.delete(webContentsId); + return; + } + + this.pendingPathByWebContentsId.set(webContentsId, normalizedPath); + } + + take(webContentsId: number): string | null { + const projectPath = this.pendingPathByWebContentsId.get(webContentsId) ?? null; + this.pendingPathByWebContentsId.delete(webContentsId); + return projectPath; + } + + delete(webContentsId: number): void { + this.pendingPathByWebContentsId.delete(webContentsId); + } + + private normalizeProjectPath(projectPath: string | null | undefined): string | null { + if (typeof projectPath !== "string") { + return null; + } + + const trimmedPath = projectPath.trim(); + return trimmedPath ? trimmedPath : null; + } +} diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index fa2ec5e12..60e78874b 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -20,6 +20,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { }, }, window: { + openNew: (options?: { pendingOpenProjectPath?: string | null }) => + ipcRenderer.invoke("paseo:window:openNew", options), getCurrentWindow: () => ({ toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"), isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"), diff --git a/packages/desktop/src/window/window-manager.ts b/packages/desktop/src/window/window-manager.ts index f5ee448a0..a56bb51c1 100644 --- a/packages/desktop/src/window/window-manager.ts +++ b/packages/desktop/src/window/window-manager.ts @@ -240,17 +240,19 @@ export function registerWindowManager(): void { } export function setupWindowResizeEvents(win: BrowserWindow): void { - win.on("resize", () => { + // A resize/fullscreen event can fire while the window is tearing down; sending + // to a destroyed webContents throws. Guard so multi-window close doesn't surface + // "Object has been destroyed" exceptions. + const notifyResized = () => { + if (win.isDestroyed() || win.webContents.isDestroyed()) { + return; + } win.webContents.send("paseo:window:resized", {}); - }); + }; - win.on("enter-full-screen", () => { - win.webContents.send("paseo:window:resized", {}); - }); - - win.on("leave-full-screen", () => { - win.webContents.send("paseo:window:resized", {}); - }); + win.on("resize", notifyResized); + win.on("enter-full-screen", notifyResized); + win.on("leave-full-screen", notifyResized); } /** From 7124a82298ba8696b35da471c054ea65583f09e6 Mon Sep 17 00:00:00 2001 From: Fabian Fischer Date: Fri, 5 Jun 2026 15:22:30 +0200 Subject: [PATCH 5/6] Authenticate file downloads with their capability token alone (#1351) The daemon's bearer middleware gated every route except /api/health behind the daemon password (PASEO_PASSWORD), including /api/files/download. That route already carries a single-use, 60s-TTL, crypto-random download token that is only ever issued over the authenticated WebSocket, so the token is the capability for the route. Requiring the daemon password on top of the token broke browser and Electron downloads: those trigger the download via an anchor navigation, which cannot attach an Authorization header. The cross-platform download store (packages/app/src/stores/download-store.ts) therefore sent no bearer, and the daemon returned 401 whenever a password was configured. Add /api/files/download to the bearer-auth bypass list. The endpoint still rejects requests without a valid token (400 missing / 403 invalid), so it stays authenticated; it just no longer demands the password a second time. This also fixes every already-released client without an app update. --- packages/server/src/server/auth.test.ts | 13 +++++++++ packages/server/src/server/auth.ts | 18 +++++++++++- .../server/src/server/bootstrap-auth.test.ts | 29 ++++++++++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 6cf208ae4..1bcf05392 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -7,6 +7,7 @@ import { hashDaemonPassword, isBearerTokenValidAsync, isBearerTokenValid, + shouldBypassBearerAuth, } from "./auth.js"; const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; @@ -52,4 +53,16 @@ describe("daemon bearer validator", () => { expect(extractWsBearerToken(protocol)).toBe("secret.with.dots"); expect(extractWsBearerToken("paseo.other.secret")).toBeNull(); }); + + test("bypasses bearer auth for preflight, liveness, and capability-token routes", () => { + // Preflight is always bypassed regardless of path. + expect(shouldBypassBearerAuth("OPTIONS", "/api/status")).toBe(true); + // Unauthenticated liveness probe. + expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); + // Guarded by its own single-use download token, not the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); + // Everything else stays behind the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); + expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false); + }); }); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 2247597f9..4e2e7ee7f 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -118,9 +118,25 @@ export function createRequireBearerMiddleware( }; } +// Routes that authenticate via their own capability and therefore must not be +// gated a second time behind the daemon password. +const BEARER_AUTH_BYPASS_PATHS = new Set([ + // Unauthenticated liveness probe. + "/api/health", + // Guarded by a single-use download token (crypto-random UUID, 60s TTL, + // consumed on first use) that is only ever issued over the + // already-authenticated WebSocket. The token IS the capability for this + // route. Requiring the daemon password on top of it breaks browser and + // Electron downloads: those trigger the download via an anchor navigation, + // which cannot attach an `Authorization` header. The download endpoint still + // rejects requests without a valid token (400/403), so dropping the bearer + // here does not make the route unauthenticated. + "/api/files/download", +]); + export function shouldBypassBearerAuth(method: string, path: string): boolean { if (method === "OPTIONS") { return true; } - return path === "/api/health"; + return BEARER_AUTH_BYPASS_PATHS.has(path); } diff --git a/packages/server/src/server/bootstrap-auth.test.ts b/packages/server/src/server/bootstrap-auth.test.ts index a8d98ef4a..2fa7c860a 100644 --- a/packages/server/src/server/bootstrap-auth.test.ts +++ b/packages/server/src/server/bootstrap-auth.test.ts @@ -65,18 +65,39 @@ describe("daemon bearer auth", () => { auth: { password: CORRECT_PASSWORD_HASH }, }); try { - const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); expect(missing.status).toBe(401); - const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer wrong-password" }, }); expect(wrong.status).toBe(401); - const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer correct-password" }, }); - expect(correct.status).toBe(400); + expect(correct.status).toBe(200); + } finally { + await daemonHandle.close(); + } + }); + + test("allows file downloads with only a capability token when password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + // No bearer at all: the route is reachable, but the download token store + // rejects the request because no token was supplied (400, not 401). + const missingToken = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + expect(missingToken.status).toBe(400); + + // An invalid token is rejected by the token store (403, not 401) — proving + // the token, not the daemon password, is what guards this route. + const invalidToken = await fetch( + `http://127.0.0.1:${daemonHandle.port}/api/files/download?token=invalid-token`, + ); + expect(invalidToken.status).toBe(403); } finally { await daemonHandle.close(); } From 0d1eecc388f552017763ae1719799f4113e72851 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 5 Jun 2026 21:35:47 +0800 Subject: [PATCH 6/6] Keep virtualenvs out of project picker results (#1356) * Fix project picker virtualenv suggestions * Ignore more virtualenv names in project picker --- .../src/utils/directory-suggestions.test.ts | 31 +++++++++++++++++++ .../server/src/utils/directory-suggestions.ts | 15 ++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 1fec0afc9..d18cab97f 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -93,6 +93,37 @@ describe("searchHomeDirectories", () => { expect(exactIndex).toBeLessThan(prefixIndex); }); + it("does not let Python virtual environments crowd out top-level project matches", async () => { + const projectPath = path.join(homeDir, "django-po-merge"); + mkdirSync(projectPath, { recursive: true }); + const dependencyPaths = ["venv", "env", "virtualenv"].map((environmentDirectoryName) => + path.join( + homeDir, + `${environmentDirectoryName}-project`, + environmentDirectoryName, + "Lib", + "site-packages", + "django", + ), + ); + for (const dependencyPath of dependencyPaths) { + mkdirSync(dependencyPath, { recursive: true }); + } + + const results = await searchHomeDirectories({ + homeDir, + query: "~/django", + limit: 30, + }); + + const resolvedResults = results.map((result) => realpathSync.native(result)); + const projectIndex = resolvedResults.indexOf(realpathSync.native(projectPath)); + expect(projectIndex).toBeGreaterThanOrEqual(0); + for (const dependencyPath of dependencyPaths) { + expect(resolvedResults).not.toContain(realpathSync.native(dependencyPath)); + } + }); + it("prioritizes partial matches that appear earlier in the path", async () => { const earlierPath = path.join(homeDir, "farofoo"); const laterPath = path.join(homeDir, "x", "y", "farofoo"); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index b889b8f08..c244535a2 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -78,8 +78,11 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; const NO_WORKSPACE_MATCH_TIER = 5; -const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([ +const IGNORED_SUGGESTION_DIRECTORY_NAMES = new Set([ "node_modules", + "venv", + "env", + "virtualenv", "dist", "build", "target", @@ -824,7 +827,9 @@ async function listChildDirectories(input: { ); const candidates = dirents.filter( (dirent) => - !isHiddenDirectoryName(dirent.name) && (dirent.isDirectory() || dirent.isSymbolicLink()), + !isHiddenDirectoryName(dirent.name) && + !isIgnoredSuggestionDirectoryName(dirent.name) && + (dirent.isDirectory() || dirent.isSymbolicLink()), ); const resolved = await Promise.all( candidates.map(async (dirent) => { @@ -864,7 +869,7 @@ async function listWorkspaceChildEntries(input: { ); const candidates = dirents.filter( (dirent) => - !isHiddenDirectoryName(dirent.name) && !isIgnoredWorkspaceDirectoryName(dirent.name), + !isHiddenDirectoryName(dirent.name) && !isIgnoredSuggestionDirectoryName(dirent.name), ); const resolved = await Promise.all( candidates.map(async (dirent) => { @@ -955,8 +960,8 @@ function isHiddenDirectoryName(name: string): boolean { return name.startsWith("."); } -function isIgnoredWorkspaceDirectoryName(name: string): boolean { - return WORKSPACE_IGNORED_DIRECTORY_NAMES.has(name); +function isIgnoredSuggestionDirectoryName(name: string): boolean { + return IGNORED_SUGGESTION_DIRECTORY_NAMES.has(name); } function setDirectoryListCache(cacheKey: string, entry: DirectoryListCacheEntry): void {