From 2595bdb7d54a8ee6239c93b39fad4ab607220016 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 21:13:40 +0000 Subject: [PATCH 1/2] feat(mobile): add another Claude account from Providers settings Mobile Settings > Providers only listed providers with in-app sign-in, so a user whose Claude account hit its limit could not add a second, already signed-in Claude config folder without the desktop app. Providers now lists each Claude instance with its sign-in status and config folder, and lets a user add one (name + CLAUDE_CONFIG_DIR folder), edit its name and folder, or remove a non-default one. Saves go through the existing mutateProviderInstance command, keep every other instance field, honor providers:manage and managed policy, and reuse the web wizard's instance-id derivation, now shared from client-runtime. Co-Authored-By: Claude Opus 5.5 --- .../src/features/settings/ClaudeAccounts.tsx | 252 ++++++++++++++++++ .../SettingsProviderAccountsRouteScreen.tsx | 64 +++-- .../features/settings/claude-accounts.test.ts | 107 ++++++++ .../src/features/settings/claude-accounts.ts | 75 ++++++ .../AddProviderInstanceDialog.logic.ts | 15 -- .../AddProviderInstanceDialog.test.ts | 13 - .../settings/AddProviderInstanceDialog.tsx | 20 +- docs/user/providers-claude.md | 3 + packages/client-runtime/package.json | 4 + .../src/providerInstanceIds.test.ts | 17 ++ .../client-runtime/src/providerInstanceIds.ts | 29 ++ 11 files changed, 536 insertions(+), 63 deletions(-) create mode 100644 apps/mobile/src/features/settings/ClaudeAccounts.tsx create mode 100644 apps/mobile/src/features/settings/claude-accounts.test.ts create mode 100644 apps/mobile/src/features/settings/claude-accounts.ts create mode 100644 packages/client-runtime/src/providerInstanceIds.test.ts create mode 100644 packages/client-runtime/src/providerInstanceIds.ts diff --git a/apps/mobile/src/features/settings/ClaudeAccounts.tsx b/apps/mobile/src/features/settings/ClaudeAccounts.tsx new file mode 100644 index 0000000000000..9b41e2a6b35bb --- /dev/null +++ b/apps/mobile/src/features/settings/ClaudeAccounts.tsx @@ -0,0 +1,252 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + isManagedSettingPath, + type ProviderInstanceId, + type ProviderInstanceMutation, + type ServerProvider, +} from "@t3tools/contracts"; +import { useRef, useState } from "react"; +import { TextInput, View } from "react-native"; + +import * as Dialog from "../../components/ui/Dialog"; +import { AppText as Text } from "../../components/ui/AppText"; +import { ControlPill } from "../../components/ui/ControlPill"; +import { ListActionRow } from "../../components/ui/ListActionRow"; +import { Menu } from "../../components/ui/Menu"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { + claudeConfigDir, + DEFAULT_CLAUDE_INSTANCE_ID, + planClaudeAccountSave, + type ClaudeAccountDraft, +} from "./claude-accounts"; +import type { SettingsTarget } from "./settings-environment-filter"; + +function useProviderInstanceMutation(environment: SettingsTarget) { + const mutate = useAtomCommand(serverEnvironment.mutateProviderInstance, { + reportFailure: false, + reportDefect: false, + }); + const pendingRef = useRef(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + async function run(providerInstanceMutation: ProviderInstanceMutation) { + if (pendingRef.current) return false; + pendingRef.current = true; + setPending(true); + setError(null); + const result = await mutate({ + environmentId: environment.environmentId, + input: { patch: {}, providerInstanceMutation }, + }); + pendingRef.current = false; + setPending(false); + if (result._tag === "Success") return true; + if (!isAtomCommandInterrupted(result)) { + const failure = squashAtomCommandFailure(result); + setError(failure instanceof Error ? failure.message : "Could not save the Claude account."); + } + return false; + } + return { run, pending, error, setError }; +} + +export function ClaudeAccount({ + environment, + provider, +}: { + readonly environment: SettingsTarget; + readonly provider: ServerProvider; +}) { + const [editing, setEditing] = useState(false); + const { run, pending, error } = useProviderInstanceMutation(environment); + const instance = environment.serverConfig.settings.providerInstances[provider.instanceId]; + const configDir = claudeConfigDir(instance); + const name = provider.displayName ?? "Claude"; + const status = !provider.enabled + ? "Turned off" + : provider.auth.status === "authenticated" + ? "Signed in" + : provider.auth.status === "unauthenticated" + ? "Not signed in" + : !provider.installed + ? "Not installed" + : null; + const removable = + provider.instanceId !== DEFAULT_CLAUDE_INSTANCE_ID && + !isManagedSettingPath( + environment.serverConfig.managedPolicy, + `providerInstances.${provider.instanceId}`, + ); + const actions: MenuAction[] = [ + { id: "edit", title: "Edit", image: "pencil" }, + ...(removable + ? [ + { + id: "remove", + title: "Remove", + image: "trash", + attributes: { destructive: true }, + } satisfies MenuAction, + ] + : []), + ]; + function remove() { + Dialog.alert( + `Remove ${name}?`, + `It leaves the model picker on ${environment.label}. The config folder and its sign-in stay on the server.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + void run({ operation: "remove", instanceId: provider.instanceId }); + }, + }, + ], + ); + } + return ( + + + + + {name} + + + {[status, configDir || "Default config folder"].filter(Boolean).join(" ยท ")} + + + {editing ? null : ( + { + if (pending) return; + if (nativeEvent.event === "edit") setEditing(true); + else if (nativeEvent.event === "remove") remove(); + }} + > + + + )} + + {editing ? ( + setEditing(false)} + /> + ) : error ? ( + + {error} + + ) : null} + + ); +} + +export function AddClaudeAccount({ environment }: { readonly environment: SettingsTarget }) { + const [adding, setAdding] = useState(false); + return ( + + {adding ? ( + setAdding(false)} + /> + ) : ( + setAdding(true)} + /> + )} + + ); +} + +function ClaudeAccountForm({ + environment, + instanceId, + initial, + onDone, +}: { + readonly environment: SettingsTarget; + readonly instanceId: ProviderInstanceId | null; + readonly initial: ClaudeAccountDraft; + readonly onDone: () => void; +}) { + const [draft, setDraft] = useState(initial); + const { run, pending, error, setError } = useProviderInstanceMutation(environment); + function save() { + const plan = planClaudeAccountSave({ + providerInstances: environment.serverConfig.settings.providerInstances, + policy: environment.serverConfig.managedPolicy, + instanceId, + draft, + }); + if ("error" in plan) { + setError(plan.error); + return; + } + void run(plan.mutation).then((saved) => { + if (saved) onDone(); + }); + } + function field(key: keyof ClaudeAccountDraft, label: string, placeholder: string) { + return ( + setDraft((current) => ({ ...current, [key]: value }))} + /> + ); + } + return ( + + + {`Point at a CLAUDE_CONFIG_DIR folder on ${environment.label} where Claude Code is already signed in. Sign in there with the Claude CLI first.`} + + {field("name", "Account name", "Name, e.g. Work")} + {field("configDir", "Claude config folder", "Config folder, e.g. ~/.claude-work")} + {error ? ( + + {error} + + ) : null} + + + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx index d7e847a182fb0..2d7e639d8b96f 100644 --- a/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProviderAccountsRouteScreen.tsx @@ -34,6 +34,8 @@ import { } from "./components/SettingsEnvironmentFilterHeader"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; +import { AddClaudeAccount, ClaudeAccount } from "./ClaudeAccounts"; +import { CLAUDE_DRIVER } from "./claude-accounts"; import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; export function SettingsProviderAccountsRouteScreen() { @@ -65,25 +67,10 @@ export function SettingsProviderAccountsRouteScreen() { ) : ( - <> - {managedProviders(environment, providersByEnvironment) - .filter(canManageProvider) - .map((provider, index) => ( - 0} - /> - ))} - {!managedProviders(environment, providersByEnvironment).some( - canManageProvider, - ) ? ( - - Configure a provider with in-app sign-in in web or desktop Settings. - - ) : null} - + )} )) @@ -94,6 +81,45 @@ export function SettingsProviderAccountsRouteScreen() { ); } +function EnvironmentProviders({ + environment, + providers, +}: { + readonly environment: SettingsTarget; + readonly providers: ReadonlyArray; +}) { + const accounts = providers.filter(canManageProvider); + // Claude has no in-app sign-in; each account is its own instance on a signed-in config folder. + const claudeAccounts = providers.filter( + (provider) => provider.driver === CLAUDE_DRIVER && !canManageProvider(provider), + ); + const canAddClaude = isManagedDriverAllowed( + environment.serverConfig.managedPolicy, + CLAUDE_DRIVER, + ); + return ( + <> + {accounts.map((provider, index) => ( + 0} + /> + ))} + {accounts.length === 0 ? ( + + Configure a provider with in-app sign-in in web or desktop Settings. + + ) : null} + {claudeAccounts.map((provider) => ( + + ))} + {canAddClaude ? : null} + + ); +} + /** Providers the environment's managed policy allows; the rest cannot run or sign in. */ function managedProviders( environment: SettingsTarget, diff --git a/apps/mobile/src/features/settings/claude-accounts.test.ts b/apps/mobile/src/features/settings/claude-accounts.test.ts new file mode 100644 index 0000000000000..38e2a5c9377b7 --- /dev/null +++ b/apps/mobile/src/features/settings/claude-accounts.test.ts @@ -0,0 +1,107 @@ +import { + ProviderDriverKind, + ProviderInstanceId, + type ManagedPolicySummary, + type ServerSettings, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { planClaudeAccountSave } from "./claude-accounts"; + +const claude = ProviderDriverKind.make("claudeAgent"); +const work = ProviderInstanceId.make("claudeAgent_work"); + +describe("planClaudeAccountSave", () => { + it("adds a signed-in config folder as a new Claude instance with a free id", () => { + const providerInstances: ServerSettings["providerInstances"] = { + [work]: { driver: claude, config: { homePath: "~/.claude-work" } }, + }; + const plan = (name: string, configDir: string) => + planClaudeAccountSave({ + providerInstances, + policy: undefined, + instanceId: null, + draft: { name, configDir }, + }); + + expect(plan(" Work ", " ~/.claude-willy ")).toEqual({ + mutation: { + operation: "create", + instanceId: "claudeAgent_work_2", + instance: { + driver: "claudeAgent", + enabled: true, + displayName: "Work", + config: { homePath: "~/.claude-willy" }, + }, + }, + }); + expect(plan("", "~/.claude-willy")).toHaveProperty("error"); + expect(plan("Willy", " ")).toHaveProperty("error"); + }); + + it("edits the name and folder without dropping the instance's other settings", () => { + const providerInstances: ServerSettings["providerInstances"] = { + [work]: { + driver: claude, + displayName: "Work", + accentColor: "#ff0000", + enabled: false, + environment: [{ name: "TOKEN", value: "", sensitive: true, valueRedacted: true }], + config: { homePath: "~/.claude-work", binaryPath: "/opt/claude", launchArgs: "--chrome" }, + }, + }; + const edit = (name: string, configDir: string) => + planClaudeAccountSave({ + providerInstances, + policy: undefined, + instanceId: work, + draft: { name, configDir }, + }); + + expect(edit("Willy", "~/.claude-willy")).toEqual({ + mutation: { + operation: "upsert", + instanceId: work, + instance: { + ...providerInstances[work], + displayName: "Willy", + config: { + binaryPath: "/opt/claude", + launchArgs: "--chrome", + homePath: "~/.claude-willy", + }, + }, + }, + }); + // Clearing both falls back to the driver's label and the default ~/.claude folder. + expect(edit("", "")).toEqual({ + mutation: { + operation: "upsert", + instanceId: work, + instance: { + driver: claude, + accentColor: "#ff0000", + enabled: false, + environment: providerInstances[work]!.environment, + config: { binaryPath: "/opt/claude", launchArgs: "--chrome" }, + }, + }, + }); + }); + + it("refuses instances the managed policy pins", () => { + const policy: ManagedPolicySummary = { + organization: "Example Corp", + lockedSettings: ["providerInstances.claudeAgent_work.config.homePath"], + }; + expect( + planClaudeAccountSave({ + providerInstances: { [work]: { driver: claude } }, + policy, + instanceId: work, + draft: { name: "Work", configDir: "~/.claude-other" }, + }), + ).toEqual({ error: "Managed by Example Corp." }); + }); +}); diff --git a/apps/mobile/src/features/settings/claude-accounts.ts b/apps/mobile/src/features/settings/claude-accounts.ts new file mode 100644 index 0000000000000..c6295e0927a12 --- /dev/null +++ b/apps/mobile/src/features/settings/claude-accounts.ts @@ -0,0 +1,75 @@ +import { + deriveAvailableInstanceId, + deriveInstanceId, +} from "@t3tools/client-runtime/provider-instance-ids"; +import { + defaultInstanceIdForDriver, + isManagedSettingPath, + ProviderDriverKind, + ProviderInstanceId, + type ManagedPolicySummary, + type ProviderInstanceConfig, + type ProviderInstanceMutation, + type ServerSettings, +} from "@t3tools/contracts"; + +export const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); +export const DEFAULT_CLAUDE_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_DRIVER); + +export interface ClaudeAccountDraft { + readonly name: string; + readonly configDir: string; +} + +function configRecord(instance: ProviderInstanceConfig | undefined): Record { + const config = instance?.config; + return config !== null && typeof config === "object" ? (config as Record) : {}; +} + +export function claudeConfigDir(instance: ProviderInstanceConfig | undefined): string { + const homePath = configRecord(instance).homePath; + return typeof homePath === "string" ? homePath : ""; +} + +/** Adds a Claude instance (`instanceId` null) or edits one, keeping every field the form doesn't show. */ +export function planClaudeAccountSave(input: { + readonly providerInstances: ServerSettings["providerInstances"]; + readonly policy: ManagedPolicySummary | undefined; + readonly instanceId: ProviderInstanceId | null; + readonly draft: ClaudeAccountDraft; +}): { readonly mutation: ProviderInstanceMutation } | { readonly error: string } { + const name = input.draft.name.trim(); + const configDir = input.draft.configDir.trim(); + const adding = input.instanceId === null; + if (adding && name.length === 0) return { error: "Enter a name for this account." }; + if (adding && configDir.length === 0) { + return { error: "Enter the Claude config folder this account is signed in with." }; + } + const instanceId = + input.instanceId ?? + ProviderInstanceId.make( + deriveAvailableInstanceId( + (label) => deriveInstanceId(CLAUDE_DRIVER, label) || CLAUDE_DRIVER, + name, + new Set([DEFAULT_CLAUDE_INSTANCE_ID, ...Object.keys(input.providerInstances)]), + ), + ); + if (input.policy && isManagedSettingPath(input.policy, `providerInstances.${instanceId}`)) { + return { error: `Managed by ${input.policy.organization}.` }; + } + const current: ProviderInstanceConfig = Object.hasOwn(input.providerInstances, instanceId) + ? input.providerInstances[instanceId]! + : { driver: CLAUDE_DRIVER }; + const { displayName: _displayName, ...rest } = current; + const { homePath: _homePath, ...config } = configRecord(current); + const nextConfig = configDir ? { ...config, homePath: configDir } : config; + const instance: ProviderInstanceConfig = { + ...rest, + ...(adding ? { enabled: true } : {}), + ...(name ? { displayName: name } : {}), + ...(Object.keys(nextConfig).length > 0 ? { config: nextConfig } : {}), + }; + return { + mutation: { operation: adding ? "create" : "upsert", instanceId, instance }, + }; +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts index 6b3f22a2a73a8..1263c675e05b7 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts @@ -39,21 +39,6 @@ export function updateProviderIdentityDraft( }; } -export function deriveAvailableInstanceId( - derive: (label: string) => string, - label: string, - existing: ReadonlySet, -): string { - const base = derive(label); - if (!base || !existing.has(base)) return base; - - for (let suffix = 2; ; suffix += 1) { - const suffixText = `_${suffix}`; - const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`; - if (!existing.has(candidate)) return candidate; - } -} - export function isConfiguredAcpRegistryAgent( instances: Readonly>, agentId: string, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts index 8abbb1174f295..7ebfadc111896 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - deriveAvailableInstanceId, getProviderIdentityDraft, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, @@ -50,18 +49,6 @@ describe("ACP Registry wizard", () => { ).toEqual({ kind: "navigate", step: 1 }); }); - it("derives a collision-free instance id without exceeding the slug limit", () => { - const derive = (label: string) => `acpRegistry_${label}`; - const existing = new Set(["acpRegistry_gemini", "acpRegistry_gemini_2"]); - - expect(deriveAvailableInstanceId(derive, "gemini", existing)).toBe("acpRegistry_gemini_3"); - - const longBase = `acpRegistry_${"a".repeat(48)}`; - expect(deriveAvailableInstanceId(() => longBase, "ignored", new Set([longBase]))).toHaveLength( - 62, - ); - }); - it("keeps registry-prefilled identity separate from other drivers", () => { const registryDrafts = updateProviderIdentityDraft({}, "acpRegistry", { label: "Gemini CLI", diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 31e298beec2c8..9b1829879405f 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -3,6 +3,10 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { CheckIcon, ChevronDownIcon } from "lucide-react"; import { useState } from "react"; +import { + deriveAvailableInstanceId, + deriveInstanceId, +} from "@t3tools/client-runtime/provider-instance-ids"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; import { @@ -43,7 +47,6 @@ import { ADD_PROVIDER_WIZARD_STEPS, ACP_REGISTRY_WIZARD_STEPS, LOCAL_ACP_WIZARD_STEPS, - deriveAvailableInstanceId, resolveAcpRegistryWizardNavigation, resolveWizardNavigation, updateProviderIdentityDraft, @@ -59,21 +62,6 @@ import { MultipleAccountsNote } from "./MultipleAccountsNote"; import { ProviderEnvironmentSection } from "./ProviderInstanceCard"; import { useSecretSources } from "./SecretSourceInput"; -/** Limit the slug to 48 chars so the composed ProviderInstanceId fits its 64-char cap. */ -function slugifyLabel(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 48); -} - -function deriveInstanceId(driver: ProviderDriverKind, label: string): string { - const slug = slugifyLabel(label); - return slug ? `${driver}_${slug}` : ""; -} - const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 0ed918a9d0580..7ba6d356d4a28 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -24,6 +24,9 @@ Add another Claude instance in **Settings > Providers**: | Claude Work | `claude` | Leave empty | | Claude Personal | `claude` | `~/.claude_personal` | +On mobile, tap **Add Claude account** in **Settings > Providers** and enter a name and the +config folder. The login itself still happens on the environment's machine. + An empty config-directory setting uses Claude Code's normal configuration. The custom setting changes `CLAUDE_CONFIG_DIR`, leaving `HOME` and the system keychain location intact. Use the same variable for the login command. Setting `HOME` diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 6fd4550d93f31..79dc8173130b1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -518,6 +518,10 @@ "./provider-clients": { "types": "./src/providerClients.ts", "default": "./src/providerClients.ts" + }, + "./provider-instance-ids": { + "types": "./src/providerInstanceIds.ts", + "default": "./src/providerInstanceIds.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/providerInstanceIds.test.ts b/packages/client-runtime/src/providerInstanceIds.test.ts new file mode 100644 index 0000000000000..2609061f8116e --- /dev/null +++ b/packages/client-runtime/src/providerInstanceIds.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveAvailableInstanceId } from "./providerInstanceIds.ts"; + +describe("deriveAvailableInstanceId", () => { + it("derives a collision-free instance id without exceeding the slug limit", () => { + const derive = (label: string) => `acpRegistry_${label}`; + const existing = new Set(["acpRegistry_gemini", "acpRegistry_gemini_2"]); + + expect(deriveAvailableInstanceId(derive, "gemini", existing)).toBe("acpRegistry_gemini_3"); + + const longBase = `acpRegistry_${"a".repeat(48)}`; + expect(deriveAvailableInstanceId(() => longBase, "ignored", new Set([longBase]))).toHaveLength( + 62, + ); + }); +}); diff --git a/packages/client-runtime/src/providerInstanceIds.ts b/packages/client-runtime/src/providerInstanceIds.ts new file mode 100644 index 0000000000000..942fdb6745398 --- /dev/null +++ b/packages/client-runtime/src/providerInstanceIds.ts @@ -0,0 +1,29 @@ +/** Limit the slug to 48 chars so the composed ProviderInstanceId fits its 64-char cap. */ +function slugifyLabel(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 48); +} + +export function deriveInstanceId(driver: string, label: string): string { + const slug = slugifyLabel(label); + return slug ? `${driver}_${slug}` : ""; +} + +export function deriveAvailableInstanceId( + derive: (label: string) => string, + label: string, + existing: ReadonlySet, +): string { + const base = derive(label); + if (!base || !existing.has(base)) return base; + + for (let suffix = 2; ; suffix += 1) { + const suffixText = `_${suffix}`; + const candidate = `${base.slice(0, 64 - suffixText.length)}${suffixText}`; + if (!existing.has(candidate)) return candidate; + } +} From 351b8d356635eb6c48f0ba4a4fda17790d42579d Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 1 Nov 2026 21:32:51 +0000 Subject: [PATCH 2/2] fix(mobile): reset Claude account config folder when cleared --- .../src/features/settings/claude-accounts.test.ts | 10 ++++++++++ apps/mobile/src/features/settings/claude-accounts.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/settings/claude-accounts.test.ts b/apps/mobile/src/features/settings/claude-accounts.test.ts index 38e2a5c9377b7..b6ca13536e716 100644 --- a/apps/mobile/src/features/settings/claude-accounts.test.ts +++ b/apps/mobile/src/features/settings/claude-accounts.test.ts @@ -88,6 +88,16 @@ describe("planClaudeAccountSave", () => { }, }, }); + expect( + planClaudeAccountSave({ + providerInstances: { [work]: { driver: claude, config: { homePath: "~/.claude-work" } } }, + policy: undefined, + instanceId: work, + draft: { name: "", configDir: "" }, + }), + ).toEqual({ + mutation: { operation: "upsert", instanceId: work, instance: { driver: claude } }, + }); }); it("refuses instances the managed policy pins", () => { diff --git a/apps/mobile/src/features/settings/claude-accounts.ts b/apps/mobile/src/features/settings/claude-accounts.ts index c6295e0927a12..820e7336864e6 100644 --- a/apps/mobile/src/features/settings/claude-accounts.ts +++ b/apps/mobile/src/features/settings/claude-accounts.ts @@ -60,7 +60,7 @@ export function planClaudeAccountSave(input: { const current: ProviderInstanceConfig = Object.hasOwn(input.providerInstances, instanceId) ? input.providerInstances[instanceId]! : { driver: CLAUDE_DRIVER }; - const { displayName: _displayName, ...rest } = current; + const { displayName: _displayName, config: _config, ...rest } = current; const { homePath: _homePath, ...config } = configRecord(current); const nextConfig = configDir ? { ...config, homePath: configDir } : config; const instance: ProviderInstanceConfig = {