From 5a433244d0b827176df8b76a05959cd1a7a98ce2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 16:11:20 -0700 Subject: [PATCH 01/69] feat: add custom model names and option descriptors (#9807) --- .../src/provider/ClaudeModelCatalog.test.ts | 56 +++ .../server/src/provider/ClaudeModelCatalog.ts | 67 +-- .../src/provider/Layers/CodexProvider.ts | 44 +- .../src/provider/Layers/GrokProvider.ts | 3 +- .../src/provider/providerSnapshot.test.ts | 21 + apps/server/src/provider/providerSnapshot.ts | 23 +- .../components/settings/CustomModelEditor.tsx | 387 ++++++++++++++++++ .../settings/ProviderInstanceCard.test.ts | 43 +- .../settings/ProviderInstanceCard.tsx | 51 ++- .../settings/ProviderModelsSection.tsx | 103 +++-- .../settings/customModelEditor.logic.test.ts | 259 ++++++++++++ .../settings/customModelEditor.logic.ts | 258 ++++++++++++ apps/web/src/modelSelection.ts | 50 ++- docs/user/composer.md | 6 + packages/contracts/src/model.ts | 16 + packages/contracts/src/settings.test.ts | 34 ++ packages/contracts/src/settings.ts | 25 +- packages/shared/src/model.test.ts | 52 +++ packages/shared/src/model.ts | 75 +++- 19 files changed, 1423 insertions(+), 150 deletions(-) create mode 100644 apps/web/src/components/settings/CustomModelEditor.tsx create mode 100644 apps/web/src/components/settings/customModelEditor.logic.test.ts create mode 100644 apps/web/src/components/settings/customModelEditor.logic.ts diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index b370c8e24d3d..d5c9d8f53d1e 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -7,9 +7,11 @@ import { formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, + resolveClaudeCatalogEffort, resolveClaudeModelCatalog, resolveClaudeModelsForVersion, resolveClaudeModelSlug, + scopeClaudeModelCatalog, } from "./ClaudeModelCatalog.ts"; /** @@ -134,4 +136,58 @@ describe("Claude model catalog", () => { }; assert.isFalse(hasValidClaudeManifestAdapters(malformed)); }); + + it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { + const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [ + "synthetic", + { + slug: "claude-custom-tuned", + name: "Tuned", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "gentle", label: "Gentle", isDefault: true }, + { id: "brutal", label: "Brutal" }, + ], + }, + ], + }, + }, + ]); + + // The bare custom slug shadows the built-in alias, so it no longer resolves to it. + assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic"); + assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined); + + // The entry with descriptors resolves user-defined effort ids and passes + // them through untouched (no effortMap, no model suffix). + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"), + "brutal", + ); + assert.strictEqual( + resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"), + "gentle", + ); + assert.strictEqual( + normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"), + "brutal", + ); + assert.strictEqual( + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-custom-tuned", + options: [{ id: "effort", value: "brutal" }], + }), + "claude-custom-tuned", + ); + assert.deepStrictEqual( + resolveClaudeModelsForVersion(catalog, "3.2.0").map((model) => model.slug), + ["claude-synthetic-next", "claude-custom-tuned"], + ); + }); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index bd554f042f0b..b1fcd6a92bbf 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -1,4 +1,5 @@ import { + type CustomModelSetting, type ModelCapabilities, type ModelSelection, ProviderDriverKind, @@ -9,7 +10,7 @@ import { getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, - normalizeCustomModelSlug, + readCustomModelEntries, } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; @@ -70,33 +71,51 @@ export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeMo export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); -/** Keeps custom model aliases opaque while preserving canonical built-in models and capabilities. */ +/** + * Scope the catalog to one instance's settings: custom model slugs stay opaque + * (a built-in alias they shadow is dropped, canonical slugs and capabilities + * are preserved), and custom entries that declare their own capabilities are + * appended so the adapter resolves effort / fast mode / thinking against the + * user's descriptors instead of the empty default. Custom entries carry no + * runtime profile, so option values pass through to Claude Code verbatim. + */ export function scopeClaudeModelCatalog( catalog: ClaudeModelCatalog, - customModels: ReadonlyArray, + customModels: ReadonlyArray, ): ClaudeModelCatalog { - const customAliases = new Set( - customModels.flatMap((model) => { - const slug = normalizeCustomModelSlug(model); - return slug ? [slug.toLowerCase()] : []; - }), - ); - if (customAliases.size === 0) return catalog; + const customEntries = readCustomModelEntries(customModels); + if (customEntries.length === 0) return catalog; + const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase())); - return { - models: catalog.models.map((entry) => { - if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { - return entry; - } - return { - ...entry, - model: { - ...entry.model, - aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), - }, - }; - }), - }; + const builtInModels = catalog.models.map((entry) => { + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { + return entry; + } + return { + ...entry, + model: { + ...entry.model, + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), + }, + }; + }); + const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug)); + const customCatalogModels: Array = []; + for (const entry of customEntries) { + if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue; + customCatalogModels.push({ + model: { + slug: entry.slug, + name: entry.name, + isCustom: true, + capabilities: entry.capabilities, + }, + runtime: {}, + compatibility: {}, + }); + } + + return { models: [...builtInModels, ...customCatalogModels] }; } export function resolveClaudeCatalogModel( diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index ac35ee138a8b..d65a09c6d4f9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -15,6 +15,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import type { CodexSettings, + CustomModelSetting, ServerProvider, ServerProviderState, ModelCapabilities, @@ -24,7 +25,7 @@ import type { } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; +import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { @@ -244,9 +245,14 @@ export function applyPreferredCodexDefaultModel( }); } +/** + * Codex has no static default capability set, so a bare custom slug borrows + * the first built-in's descriptors; an entry with its own capabilities keeps + * them. + */ function appendCustomCodexModels( models: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, ): ReadonlyArray { if (customModels.length === 0) { return models; @@ -255,17 +261,16 @@ function appendCustomCodexModels( const seen = new Set(models.map((model) => model.slug)); const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; const customEntries: ServerProviderModel[] = []; - for (const rawModel of customModels) { - const slug = rawModel.trim(); - if (!slug || seen.has(slug)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(slug); + seen.add(entry.slug); customEntries.push({ - slug, - name: slug, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: fallbackCapabilities, + capabilities: entry.capabilities ?? fallbackCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; @@ -399,7 +404,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; + readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { const { client, initialize } = yield* withCodexAppServerClient(input); @@ -470,21 +475,8 @@ export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(functi return parseCodexSkillsListResponse(skillsResponse, input.cwd); }); -const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { - const models = new Set(); - for (const model of codexSettings.customModels) { - const trimmed = model.trim(); - if (trimmed.length > 0) { - models.add(trimmed); - } - } - return Array.from(models, (model) => ({ - slug: model, - name: model, - isCustom: true, - capabilities: null, - })); -}; +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => + appendCustomCodexModels([], codexSettings.customModels); const makePendingCodexProvider = ( codexSettings: CodexSettings, @@ -562,7 +554,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 50a881f38897..493e46d44352 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,4 +1,5 @@ import { + type CustomModelSetting, type GrokSettings, type ModelCapabilities, type ServerProvider, @@ -104,7 +105,7 @@ export function buildInitialGrokProviderSnapshot( } function grokModelsFromSettings( - customModels: ReadonlyArray | undefined, + customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 011572780666..399d86f7a133 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -52,6 +52,27 @@ describe("providerModelsFromSettings", () => { ]); }); + it("keeps an entry's own name and capabilities over the driver default", () => { + const capabilities = createModelCapabilities({ + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }); + const models = providerModelsFromSettings( + [], + ["bare", { slug: "named", name: "Named", capabilities }], + OPENCODE_CUSTOM_MODEL_CAPABILITIES, + ); + + expect(models).toEqual([ + { + slug: "bare", + name: "bare", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + { slug: "named", name: "Named", isCustom: true, capabilities }, + ]); + }); + it("preserves a custom slug that collides with a provider alias", () => { const capabilities = createModelCapabilities({ optionDescriptors: [] }); const models = providerModelsFromSettings( diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 55534629d3eb..9663aeacb63f 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + CustomModelSetting, ProviderDriverKind, ModelCapabilities, ServerProvider, @@ -14,7 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -145,26 +146,30 @@ export function parseGenericCliVersion(output: string): string | null { return match?.[1] ?? null; } +/** + * Append the user's custom models after the built-ins. A custom entry that + * declares its own capabilities keeps them; a bare slug gets the driver's + * default set. Slugs that collide with a built-in are dropped. + */ export function providerModelsFromSettings( builtInModels: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); const customEntries: ServerProviderModel[] = []; - for (const candidate of customModels) { - const normalized = normalizeCustomModelSlug(candidate); - if (!normalized || seen.has(normalized)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(normalized); + seen.add(entry.slug); customEntries.push({ - slug: normalized, - name: normalized, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: customModelCapabilities, + capabilities: entry.capabilities ?? customModelCapabilities, }); } diff --git a/apps/web/src/components/settings/CustomModelEditor.tsx b/apps/web/src/components/settings/CustomModelEditor.tsx new file mode 100644 index 000000000000..9fa65e0bf23e --- /dev/null +++ b/apps/web/src/components/settings/CustomModelEditor.tsx @@ -0,0 +1,387 @@ +"use client"; + +import { PlusIcon, XIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import type { CustomModelDefinition } from "@t3tools/shared/model"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { + DESCRIPTOR_PRESETS_BY_KIND, + type CustomModelDraft, + type EditorChoice, + type EditorDescriptor, + choiceFromPreset, + definitionFromDraft, + descriptorFromPreset, + descriptorsFromCapabilities, + draftFromDefinition, + emptyEditorChoice, + emptyEditorDescriptor, + validateDraft, +} from "./customModelEditor.logic"; + +const CUSTOM_ID_VALUE = "__custom__"; +const START_FROM_NONE = "__none__"; + +interface CustomModelEditorProps { + readonly instanceId: string; + readonly driverKind: ProviderDriverKind | null; + readonly entry: CustomModelDefinition; + /** Built-in models whose descriptors can be copied as a starting point. */ + readonly builtInModels: ReadonlyArray; + readonly onSave: (next: CustomModelDefinition) => void; + readonly onCancel: () => void; +} + +/** + * Inline editor for one custom model: display name plus the option + * descriptors the composer should offer for it (Reasoning effort, Fast + * mode, ...). Draft state is local; nothing is persisted until Save. + */ +export function CustomModelEditor({ + instanceId, + driverKind, + entry, + builtInModels, + onSave, + onCancel, +}: CustomModelEditorProps) { + const [draft, setDraft] = useState(() => draftFromDefinition(entry)); + const [error, setError] = useState(null); + const presets = useMemo( + () => (driverKind ? (DESCRIPTOR_PRESETS_BY_KIND[driverKind] ?? []) : []), + [driverKind], + ); + const startFromCandidates = useMemo( + () => builtInModels.filter((model) => (model.capabilities?.optionDescriptors?.length ?? 0) > 0), + [builtInModels], + ); + const domId = (suffix: string) => `provider-instance-${instanceId}-custom-model-${suffix}`; + + const updateDescriptor = (key: string, patch: Partial) => { + setError(null); + setDraft((current) => ({ + ...current, + descriptors: current.descriptors.map((descriptor) => + descriptor.key === key ? { ...descriptor, ...patch } : descriptor, + ), + })); + }; + + const updateChoice = (descriptorKey: string, choiceKey: string, patch: Partial) => { + setError(null); + setDraft((current) => ({ + ...current, + descriptors: current.descriptors.map((descriptor) => { + if (descriptor.key !== descriptorKey) return descriptor; + return { + ...descriptor, + choices: descriptor.choices.map((choice) => { + if (choice.key === choiceKey) return { ...choice, ...patch }; + // Only one choice can be the default. + return patch.isDefault ? { ...choice, isDefault: false } : choice; + }), + }; + }), + })); + }; + + const removeDescriptor = (key: string) => { + setError(null); + setDraft((current) => ({ + ...current, + descriptors: current.descriptors.filter((descriptor) => descriptor.key !== key), + })); + }; + + const addDescriptor = (descriptor: EditorDescriptor) => { + setError(null); + setDraft((current) => ({ ...current, descriptors: [...current.descriptors, descriptor] })); + }; + + // Selecting a preset id replaces the descriptor's label/type/choices so the + // usual values are one click away; "Custom…" leaves the row blank to type into. + const applyPresetId = (descriptor: EditorDescriptor, value: string | null) => { + if (value === null) return; + if (value === CUSTOM_ID_VALUE) { + updateDescriptor(descriptor.key, { id: "" }); + return; + } + const preset = presets.find((candidate) => candidate.id === value); + if (!preset) return; + updateDescriptor(descriptor.key, { + id: preset.id, + label: preset.label, + type: preset.type, + choices: (preset.choices ?? []).map(choiceFromPreset), + currentBooleanValue: undefined, + description: undefined, + }); + }; + + const handleStartFrom = (slug: string | null) => { + if (slug === null || slug === START_FROM_NONE) return; + const model = startFromCandidates.find((candidate) => candidate.slug === slug); + if (!model) return; + setError(null); + setDraft((current) => ({ + ...current, + descriptors: descriptorsFromCapabilities(model.capabilities, driverKind), + })); + }; + + const handleSave = () => { + const problem = validateDraft(draft); + if (problem) { + setError(problem); + return; + } + onSave(definitionFromDraft(draft)); + }; + + const idSelectValue = (descriptor: EditorDescriptor) => + presets.some((preset) => preset.id === descriptor.id) ? descriptor.id : CUSTOM_ID_VALUE; + + const renderChoice = (descriptor: EditorDescriptor, choice: EditorChoice) => ( +
+ updateChoice(descriptor.key, choice.key, { id: event.target.value })} + placeholder="value" + className="w-28 font-mono" + spellCheck={false} + aria-label="Choice value" + /> + + updateChoice(descriptor.key, choice.key, { label: event.target.value }) + } + placeholder="Label" + className="min-w-0 flex-1" + aria-label="Choice label" + /> + + +
+ ); + + const renderDescriptor = (descriptor: EditorDescriptor, index: number) => ( +
+
+ Option {index + 1} + {presets.length > 0 ? ( + + ) : null} + {idSelectValue(descriptor) === CUSTOM_ID_VALUE ? ( + updateDescriptor(descriptor.key, { id: event.target.value })} + placeholder="optionId" + className="w-36 font-mono" + spellCheck={false} + aria-label="Option id" + /> + ) : null} + updateDescriptor(descriptor.key, { label: event.target.value })} + placeholder="Label" + className="min-w-0 flex-1" + aria-label="Option label" + /> + + +
+ {descriptor.type === "select" ? ( +
+ {descriptor.choices.map((choice) => renderChoice(descriptor, choice))} + +
+ ) : null} +
+ ); + + return ( +
{ + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }} + > +
+ + setDraft((current) => ({ ...current, name: event.target.value }))} + placeholder={draft.slug} + className="sm:w-72" + spellCheck={false} + /> +
+ +
+
+ Options shown in the composer + {startFromCandidates.length > 0 ? ( + + ) : null} +
+ {draft.descriptors.length === 0 ? ( +

+ No custom options. The composer uses the provider's default options. +

+ ) : null} + {draft.descriptors.map(renderDescriptor)} +
+ {presets + .filter( + (preset) => !draft.descriptors.some((descriptor) => descriptor.id === preset.id), + ) + .map((preset) => ( + + ))} + +
+
+ + {error ?

{error}

: null} + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index ed62ff055b09..a5085774f0c6 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -36,11 +36,52 @@ describe("deriveProviderModelsForDisplay", () => { expect( deriveProviderModelsForDisplay({ liveModels, - customModels: ["kept-custom"], + customModels: [{ slug: "kept-custom", name: "kept-custom", capabilities: null }], }).map((model) => model.slug), ).toEqual(["server-model", "kept-custom"]); }); + it("prefers the entry's name and capabilities over the stale live custom row", () => { + const liveCapabilities = { optionDescriptors: [] }; + const customCapabilities = { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: [{ id: "high", label: "High", isDefault: true }], + currentValue: "high", + }, + ], + }; + const liveModels: ReadonlyArray = [ + { slug: "bare", name: "bare", isCustom: true, capabilities: liveCapabilities }, + { slug: "named", name: "named", isCustom: true, capabilities: liveCapabilities }, + ]; + + const display = deriveProviderModelsForDisplay({ + liveModels, + customModels: [ + { slug: "bare", name: "bare", capabilities: null }, + { slug: "named", name: "My Model", capabilities: customCapabilities }, + ], + }); + + // A bare entry keeps the driver default the server filled in. + expect(display[0]).toEqual({ + slug: "bare", + name: "bare", + isCustom: true, + capabilities: liveCapabilities, + }); + expect(display[1]).toEqual({ + slug: "named", + name: "My Model", + isCustom: true, + capabilities: customCapabilities, + }); + }); + it("shows a redacted provider email in the editor header status line", () => { const instanceId = ProviderInstanceId.make("codex"); const driver = ProviderDriverKind.make("codex"); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 942d146e929f..5c96de9d0545 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -25,6 +25,11 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; +import { + type CustomModelDefinition, + readCustomModelEntries, + toCustomModelSetting, +} from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; @@ -96,16 +101,13 @@ function providerEnvironmentsEqual( } /** - * Read a string[] at `key` from the opaque config blob, filtering out - * non-string entries. Used for `customModels`, which is always typed as - * `string[]` by the concrete driver schemas but arrives here as - * `Schema.Unknown`. + * Read `customModels` from the opaque config blob. The concrete driver + * schemas type it as `CustomModelSetting[]`, but it arrives here as + * `Schema.Unknown`, so the shared reader does the shape checking. */ -function readConfigStringArray(config: unknown, key: string): ReadonlyArray { +function readConfigCustomModels(config: unknown): ReadonlyArray { if (config === null || typeof config !== "object") return []; - const value = (config as Record)[key]; - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); + return readCustomModelEntries((config as Record).customModels); } /** @@ -127,9 +129,14 @@ function nextConfigBlobWithValue( return base; } +/** + * Custom rows come from current settings so name/descriptor edits show + * instantly; a bare entry falls back to the live row's driver-default + * capabilities (the server fills those in on its next probe). + */ export function deriveProviderModelsForDisplay(input: { readonly liveModels: ReadonlyArray | undefined; - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; }): ReadonlyArray { const liveCustomModelsBySlug = new Map( Arr.filterMap(input.liveModels ?? [], (model) => @@ -137,15 +144,13 @@ export function deriveProviderModelsForDisplay(input: { ), ); const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; - const customModels = input.customModels.map( - (slug) => - liveCustomModelsBySlug.get(slug) ?? { - slug, - name: slug, - isCustom: true, - capabilities: null, - }, - ); + const customModels = input.customModels.map((entry) => ({ + slug: entry.slug, + name: entry.name, + isCustom: true, + capabilities: + entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null, + })); return [...serverModels, ...customModels]; } @@ -463,7 +468,7 @@ export function ProviderInstanceCard({ ? instance.driver : null; const customModels = - instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); + instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config); // Server-returned models may lag behind settings writes. Treat probe // models as the source for built-ins only; custom rows come directly // from the current instance config so add/remove reflects immediately. @@ -504,8 +509,12 @@ export function ProviderInstanceCard({ ); }; - const updateCustomModels = (next: ReadonlyArray) => { - const nextConfig = nextConfigBlobWithValue(instance.config, "customModels", [...next]); + const updateCustomModels = (next: ReadonlyArray) => { + const nextConfig = nextConfigBlobWithValue( + instance.config, + "customModels", + next.map(toCustomModelSetting), + ); const { config: _omit, ...rest } = instance; onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); }; diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7b98dbe42991..7866578cfa4a 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -1,13 +1,13 @@ "use client"; -import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; +import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderDriverKind, type ProviderInstanceId, type ServerProviderModel, } from "@t3tools/contracts"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { sortModelsForProviderInstance } from "../../modelOrdering"; @@ -16,6 +16,7 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { CustomModelEditor } from "./CustomModelEditor"; /** * Placeholder text for the "add a custom model" input, keyed by driver @@ -109,11 +110,11 @@ interface ProviderModelsSectionProps { */ readonly models: ReadonlyArray; /** - * The persisted custom-model slug list for this instance. Drives dedup, - * and is the array we hand back verbatim (with the new slug appended / + * The persisted custom-model list for this instance, resolved. Drives + * dedup, and is the list we hand back (with an entry appended / replaced / * removed) via `onChange`. */ - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; /** Server-returned model slugs hidden from the model picker. */ readonly hiddenModels: ReadonlyArray; /** Model slugs favorited for this provider instance. */ @@ -125,7 +126,7 @@ interface ProviderModelsSectionProps { * write to the correct storage (legacy `settings.providers[kind]` vs. * `providerInstances[id].config`). */ - readonly onChange: (next: ReadonlyArray) => void; + readonly onChange: (next: ReadonlyArray) => void; readonly onHiddenModelsChange: (next: ReadonlyArray) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; @@ -159,6 +160,8 @@ export function ProviderModelsSection({ const [isAdding, setIsAdding] = useState(false); const [filter, setFilter] = useState(""); const [error, setError] = useState(null); + // Slug of the custom model whose inline editor is open, if any. + const [editingSlug, setEditingSlug] = useState(null); const listRef = useRef(null); // Slug of a just-added custom model, scrolled into view once its row exists. const scrollToSlugRef = useRef(null); @@ -177,6 +180,7 @@ export function ProviderModelsSection({ const hiddenCount = displayModels.filter( (model) => !model.isCustom && hiddenModelSet.has(model.slug), ).length; + const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); const showFilter = models.length > FILTER_THRESHOLD; const normalizedFilter = filter.trim().toLowerCase(); const isFiltering = showFilter && normalizedFilter.length > 0; @@ -216,7 +220,7 @@ export function ProviderModelsSection({ setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); return; } - if (customModels.includes(normalized)) { + if (customModels.some((entry) => entry.slug === normalized)) { setError("That custom model is already saved."); return; } @@ -225,7 +229,7 @@ export function ProviderModelsSection({ // which is also what lets the pending scroll target resolve and clear. scrollToSlugRef.current = normalized; setFilter(""); - onChange([...customModels, normalized]); + onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]); setInput(""); setError(null); setIsAdding(false); @@ -238,12 +242,18 @@ export function ProviderModelsSection({ }; const handleRemove = (slug: string) => { - onChange(customModels.filter((model) => model !== slug)); + if (editingSlug === slug) setEditingSlug(null); + onChange(customModels.filter((entry) => entry.slug !== slug)); onModelOrderChange(modelOrder.filter((model) => model !== slug)); onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); setError(null); }; + const handleSaveEdit = (next: CustomModelDefinition) => { + onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); + setEditingSlug(null); + }; + const setHidden = (slug: string, hidden: boolean) => { if (hidden === hiddenModelSet.has(slug)) return; onHiddenModelsChange( @@ -355,21 +365,40 @@ export function ProviderModelsSection({ ) : null} {model.isCustom ? ( - - handleRemove(model.slug)} - /> - } - > - - - Remove custom model - + <> + + + setEditingSlug((current) => (current === model.slug ? null : model.slug)) + } + /> + } + > + + + Edit name and options + + + handleRemove(model.slug)} + /> + } + > + + + Remove custom model + + ) : null} ); @@ -420,20 +449,23 @@ export function ProviderModelsSection({ key={`${instanceId}:${model.slug}`} data-model-slug={model.slug} className={cn( - "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_4rem_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30", + // Actions column is at least wide enough for the four custom-row + // buttons so capability labels line up across built-in and custom rows. + "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_minmax(5.5rem,auto)_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30", isHidden && "opacity-50", )} > {starButton(model, isFavorite)} {model.name} - {model.isCustom ? ( - custom - ) : model.name !== model.slug ? ( + {model.name !== model.slug ? ( {model.slug} ) : null} + {model.isCustom ? ( + custom + ) : null} {/* Always a grid item so the columns line up across rows; the text @@ -489,6 +521,10 @@ export function ProviderModelsSection({ const group = groupOf(model); const previous = visibleModels[index - 1]; const startsGroup = previous === undefined || groupOf(previous) !== group; + const editingEntry = + model.isCustom && editingSlug === model.slug + ? customModels.find((entry) => entry.slug === model.slug) + : undefined; return (
{startsGroup && favoriteCount > 0 && group === "favorite" @@ -501,6 +537,17 @@ export function ProviderModelsSection({ ? groupLabel("Hidden from picker", index === 0) : null} {renderRow(model)} + {editingEntry ? ( + setEditingSlug(null)} + /> + ) : null}
); })} diff --git a/apps/web/src/components/settings/customModelEditor.logic.test.ts b/apps/web/src/components/settings/customModelEditor.logic.test.ts new file mode 100644 index 000000000000..dba4d7af7bbd --- /dev/null +++ b/apps/web/src/components/settings/customModelEditor.logic.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; + +import { + DESCRIPTOR_PRESETS_BY_KIND, + descriptorFromPreset, + definitionFromDraft, + descriptorsFromCapabilities, + draftFromDefinition, + validateDraft, + type CustomModelDraft, +} from "./customModelEditor.logic"; + +const draft = (overrides: Partial): CustomModelDraft => ({ + slug: "my-model", + name: "", + descriptors: [], + ...overrides, +}); + +describe("customModelEditor.logic", () => { + it("round-trips a definition through the draft, marking the current value as default", () => { + const definition = definitionFromDraft( + draft({ + name: " My Model ", + descriptors: [ + { + key: "a", + type: "select", + id: "reasoningEffort", + label: "Reasoning", + choices: [ + { key: "a1", id: "low", label: "Low", isDefault: false }, + { key: "a2", id: "high", label: "", isDefault: true }, + ], + }, + { key: "b", type: "boolean", id: "fastMode", label: "Fast Mode", choices: [] }, + ], + }), + ); + + expect(definition).toEqual({ + slug: "my-model", + name: "My Model", + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "high", isDefault: true }, + ], + currentValue: "high", + }, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + ], + }, + }); + + const reopened = draftFromDefinition(definition); + expect(reopened.name).toBe("My Model"); + expect(reopened.descriptors.map((descriptor) => descriptor.id)).toEqual([ + "reasoningEffort", + "fastMode", + ]); + expect(reopened.descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([ + false, + true, + ]); + }); + + it("preserves the current choice when it differs from the built-in default", () => { + const descriptors = descriptorsFromCapabilities( + { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + currentValue: "high", + options: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + }, + ], + }, + ProviderDriverKind.make("claudeAgent"), + ); + expect(descriptors[0]!.choices.map((choice) => choice.isDefault)).toEqual([false, true]); + expect( + definitionFromDraft(draft({ descriptors })).capabilities?.optionDescriptors?.[0], + ).toMatchObject({ currentValue: "high" }); + }); + + it("drops prompt-injected choices when copying a built-in's descriptors", () => { + const [copied] = descriptorsFromCapabilities( + { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + ], + }, + ProviderDriverKind.make("claudeAgent"), + ); + expect(copied!.choices.map((choice) => choice.id)).toEqual(["high"]); + }); + + it.each([true, false, undefined])( + "preserves boolean values through copy and edit: %s", + (currentValue) => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "thinking", + label: "Thinking", + type: "boolean", + ...(currentValue !== undefined ? { currentValue } : {}), + }, + ], + }; + const copied = definitionFromDraft( + draft({ + descriptors: descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")), + }), + ); + expect(copied.capabilities).toEqual(capabilities); + const reopened = draftFromDefinition(copied); + expect(definitionFromDraft({ ...reopened, name: "Renamed" }).capabilities).toEqual( + capabilities, + ); + }, + ); + + it("excludes Claude context choices from presets and copies without changing other providers or authored entries", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "contextWindow", + label: "Context", + type: "select", + options: [{ id: "1m", label: "1M", isDefault: true }], + }, + { id: "thinking", label: "Thinking", type: "boolean", currentValue: true }, + ], + }; + const claude = ProviderDriverKind.make("claudeAgent"); + const copied = definitionFromDraft( + draft({ descriptors: descriptorsFromCapabilities(capabilities, claude) }), + ); + expect(copied.capabilities?.optionDescriptors).toEqual([capabilities.optionDescriptors![1]]); + const presets = definitionFromDraft( + draft({ + descriptors: (DESCRIPTOR_PRESETS_BY_KIND[claude] ?? []).map(descriptorFromPreset), + }), + ); + expect( + presets.capabilities?.optionDescriptors?.some((option) => option.id === "contextWindow"), + ).toBe(false); + const cursorCopy = descriptorsFromCapabilities(capabilities, ProviderDriverKind.make("cursor")); + expect(cursorCopy.map((option) => option.id)).toEqual(["contextWindow", "thinking"]); + const authored = { slug: "custom", name: "Custom", capabilities }; + expect( + definitionFromDraft(draftFromDefinition(authored)).capabilities?.optionDescriptors?.[0], + ).toMatchObject(capabilities.optionDescriptors![0]!); + }); + + it("preserves choice descriptions when copying, renaming, and saving", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + description: "Choose a reasoning level.", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "ultracode", label: "Ultracode", description: "Uses additional reasoning." }, + ], + }, + ], + }; + const copied = definitionFromDraft( + draft({ + descriptors: descriptorsFromCapabilities( + capabilities, + ProviderDriverKind.make("claudeAgent"), + ), + }), + ); + const reopened = draftFromDefinition(copied); + const saved = definitionFromDraft({ ...reopened, name: "Renamed" }); + expect(saved.capabilities?.optionDescriptors?.[0]).toMatchObject( + capabilities.optionDescriptors![0]!, + ); + expect(copied.capabilities?.optionDescriptors?.[0]).toMatchObject( + capabilities.optionDescriptors![0]!, + ); + }); + + it.each(Object.entries(DESCRIPTOR_PRESETS_BY_KIND))( + "offers saveable presets for %s", + (_driver, presets) => { + expect( + validateDraft(draft({ descriptors: (presets ?? []).map(descriptorFromPreset) })), + ).toBeNull(); + }, + ); + + it("collapses a blank name and no options back to a bare definition", () => { + expect(definitionFromDraft(draft({ name: " " }))).toEqual({ + slug: "my-model", + name: "my-model", + capabilities: null, + }); + expect(draftFromDefinition({ slug: "x", name: "x", capabilities: null }).name).toBe(""); + }); + + it("rejects duplicate ids, blank ids, and selects without choices", () => { + const select = (id: string, choices: Array<{ id: string }>) => ({ + key: id, + type: "select" as const, + id, + label: "Label", + choices: choices.map((choice) => ({ + key: choice.id, + label: "", + isDefault: false, + ...choice, + })), + }); + + expect(validateDraft(draft({ descriptors: [select("", [{ id: "a" }])] }))).toBe( + "Option 1 needs an id.", + ); + expect( + validateDraft( + draft({ descriptors: [select("effort", [{ id: "a" }]), select("effort", [{ id: "b" }])] }), + ), + ).toBe('Option 2: id "effort" is used twice.'); + expect(validateDraft(draft({ descriptors: [select("effort", [])] }))).toBe( + "Option 1 needs at least one choice.", + ); + expect( + validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }, { id: "a" }])] })), + ).toBe('Option 1: choice "a" is used twice.'); + expect(validateDraft(draft({ descriptors: [select("effort", [{ id: "a" }])] }))).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/customModelEditor.logic.ts b/apps/web/src/components/settings/customModelEditor.logic.ts new file mode 100644 index 000000000000..0d48057206df --- /dev/null +++ b/apps/web/src/components/settings/customModelEditor.logic.ts @@ -0,0 +1,258 @@ +import { + type ModelCapabilities, + ProviderDriverKind, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; +import { type CustomModelDefinition, createModelCapabilities } from "@t3tools/shared/model"; + +/** Editable mirror of a `ProviderOptionChoice`. `key` is only a React key. */ +export interface EditorChoice { + readonly key: string; + readonly id: string; + readonly label: string; + readonly isDefault: boolean; + readonly description?: string; +} + +/** Editable mirror of a `ProviderOptionDescriptor`. `key` is only a React key. */ +export interface EditorDescriptor { + readonly key: string; + readonly type: "select" | "boolean"; + readonly id: string; + readonly label: string; + readonly choices: ReadonlyArray; + readonly currentBooleanValue?: boolean | undefined; + readonly description?: string | undefined; +} + +export interface CustomModelDraft { + readonly slug: string; + readonly name: string; + readonly descriptors: ReadonlyArray; +} + +export interface DescriptorPreset { + readonly id: string; + readonly label: string; + readonly type: "select" | "boolean"; + readonly choices?: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>; +} + +const EFFORT_CHOICES = [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, +] as const; + +/** + * Option ids each adapter actually reads off a turn's model selection, with + * the usual choices pre-filled. Anything else the user types is stored + * verbatim but will be ignored by the driver. + */ +export const DESCRIPTOR_PRESETS_BY_KIND: Partial< + Record> +> = { + [ProviderDriverKind.make("codex")]: [ + { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + { + id: "serviceTier", + label: "Speed", + type: "select", + choices: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "fast", label: "Fast" }, + ], + }, + ], + [ProviderDriverKind.make("claudeAgent")]: [ + { + id: "effort", + label: "Reasoning", + type: "select", + choices: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + ], + }, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + { id: "thinking", label: "Thinking", type: "boolean" }, + ], + [ProviderDriverKind.make("cursor")]: [ + { id: "reasoning", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + { id: "fastMode", label: "Fast Mode", type: "boolean" }, + { id: "thinking", label: "Thinking", type: "boolean" }, + ], + [ProviderDriverKind.make("grok")]: [ + { id: "reasoningEffort", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + ], + [ProviderDriverKind.make("opencode")]: [ + { id: "variant", label: "Reasoning", type: "select", choices: EFFORT_CHOICES }, + { + id: "agent", + label: "Agent", + type: "select", + choices: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + }, + ], +}; + +let nextKey = 0; +export function newEditorKey(): string { + nextKey += 1; + return `k${nextKey}`; +} + +export function choiceFromPreset(choice: { + id: string; + label: string; + isDefault?: boolean; +}): EditorChoice { + return { key: newEditorKey(), id: choice.id, label: choice.label, isDefault: !!choice.isDefault }; +} + +export function descriptorFromPreset(preset: DescriptorPreset): EditorDescriptor { + return { + key: newEditorKey(), + type: preset.type, + id: preset.id, + label: preset.label, + choices: (preset.choices ?? []).map(choiceFromPreset), + }; +} + +export function emptyEditorDescriptor(): EditorDescriptor { + return { key: newEditorKey(), type: "select", id: "", label: "", choices: [] }; +} + +export function emptyEditorChoice(): EditorChoice { + return { key: newEditorKey(), id: "", label: "", isDefault: false }; +} + +/** + * Prompt-injected choices (Claude's `ultrathink`) are delivered as prompt text + * by built-in runtime profiles a custom entry does not have, so they are + * dropped rather than stored as a plain option value. + */ +export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { + const promptInjected = new Set( + descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], + ); + const choices = + descriptor.type === "select" + ? descriptor.options.filter((option) => !promptInjected.has(option.id)) + : []; + const defaultChoice = + choices.find((option) => option.id === descriptor.currentValue) ?? + choices.find((option) => option.isDefault); + return { + key: newEditorKey(), + type: descriptor.type, + id: descriptor.id, + label: descriptor.label, + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), + ...(descriptor.type === "boolean" && descriptor.currentValue !== undefined + ? { currentBooleanValue: descriptor.currentValue } + : {}), + choices: choices.map((option) => ({ + key: newEditorKey(), + id: option.id, + label: option.label, + ...(option.description !== undefined ? { description: option.description } : {}), + isDefault: option === defaultChoice, + })), + }; +} + +export function draftFromDefinition(entry: CustomModelDefinition): CustomModelDraft { + return { + slug: entry.slug, + name: entry.name === entry.slug ? "" : entry.name, + descriptors: (entry.capabilities?.optionDescriptors ?? []).map(descriptorToEditor), + }; +} + +/** Claude context choices require runtime suffix mappings that custom entries do not carry. */ +export function descriptorsFromCapabilities( + capabilities: ModelCapabilities | null | undefined, + driverKind: ProviderDriverKind | null, +): EditorDescriptor[] { + return (capabilities?.optionDescriptors ?? []) + .filter((descriptor) => driverKind !== "claudeAgent" || descriptor.id !== "contextWindow") + .map(descriptorToEditor); +} + +/** + * Validate the draft before saving. Returns the first problem in reading + * order so the message is actionable, or `null` when the draft is sound. + */ +export function validateDraft(draft: CustomModelDraft): string | null { + const seenIds = new Set(); + for (const [index, descriptor] of draft.descriptors.entries()) { + const position = `Option ${index + 1}`; + const id = descriptor.id.trim(); + if (!id) return `${position} needs an id.`; + if (seenIds.has(id)) return `${position}: id "${id}" is used twice.`; + seenIds.add(id); + if (!descriptor.label.trim()) return `${position} needs a label.`; + if (descriptor.type !== "select") continue; + if (descriptor.choices.length === 0) return `${position} needs at least one choice.`; + const seenChoices = new Set(); + for (const choice of descriptor.choices) { + const choiceId = choice.id.trim(); + if (!choiceId) return `${position} has a choice without a value.`; + if (seenChoices.has(choiceId)) { + return `${position}: choice "${choiceId}" is used twice.`; + } + seenChoices.add(choiceId); + } + } + return null; +} + +/** Convert a validated draft back into a definition. Blank name → slug. */ +export function definitionFromDraft(draft: CustomModelDraft): CustomModelDefinition { + const descriptors: ProviderOptionDescriptor[] = draft.descriptors.map((descriptor) => { + const id = descriptor.id.trim(); + const label = descriptor.label.trim(); + if (descriptor.type === "boolean") { + return { + id, + label, + type: "boolean", + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), + ...(descriptor.currentBooleanValue !== undefined + ? { currentValue: descriptor.currentBooleanValue } + : {}), + }; + } + const options = descriptor.choices.map((choice) => ({ + id: choice.id.trim(), + label: choice.label.trim() || choice.id.trim(), + ...(choice.description !== undefined ? { description: choice.description } : {}), + ...(choice.isDefault ? { isDefault: true } : {}), + })); + const currentValue = options.find((option) => option.isDefault)?.id; + return { + id, + label, + type: "select", + ...(descriptor.description !== undefined ? { description: descriptor.description } : {}), + options, + ...(currentValue ? { currentValue } : {}), + }; + }); + const name = draft.name.trim(); + return { + slug: draft.slug, + name: name || draft.slug, + capabilities: + descriptors.length > 0 ? createModelCapabilities({ optionDescriptors: descriptors }) : null, + }; +} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 64937a442e7a..4ff195083d51 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -10,8 +10,10 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { + type CustomModelDefinition, createModelSelection, normalizeCustomModelSlug, + readCustomModelEntries, resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; @@ -55,14 +57,14 @@ function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind, -): ReadonlyArray { +): ReadonlyArray { if (driverKind === "antigravity") return []; const instance = settings.providerInstances?.[instanceId]; const config = instance?.config; if (config !== null && typeof config === "object") { const value = (config as Record).customModels; if (Array.isArray(value)) { - return value.filter((entry): entry is string => typeof entry === "string"); + return readCustomModelEntries(value); } } const defaultInstanceId = defaultInstanceIdForDriver(driverKind); @@ -71,9 +73,9 @@ function readInstanceCustomModels( } const legacyProviders = settings.providers as Record< string, - { readonly customModels: ReadonlyArray } | undefined + { readonly customModels: ReadonlyArray } | undefined >; - return legacyProviders[driverKind]?.customModels ?? []; + return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []); } export interface AppModelOption { @@ -151,26 +153,24 @@ function applyInstanceModelPreferences( ); } -export function normalizeCustomModelSlugs( - models: Iterable, +export function normalizeCustomModelEntries( + models: ReadonlyArray, builtInModelSlugs: ReadonlySet, -): string[] { - const normalizedModels: string[] = []; +): CustomModelDefinition[] { + const normalizedModels: CustomModelDefinition[] = []; const seen = new Set(); for (const candidate of models) { - const normalized = normalizeCustomModelSlug(candidate); if ( - !normalized || - normalized.length > MAX_CUSTOM_MODEL_LENGTH || - builtInModelSlugs.has(normalized) || - seen.has(normalized) + candidate.slug.length > MAX_CUSTOM_MODEL_LENGTH || + builtInModelSlugs.has(candidate.slug) || + seen.has(candidate.slug) ) { continue; } - seen.add(normalized); - normalizedModels.push(normalized); + seen.add(candidate.slug); + normalizedModels.push(candidate); if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { break; } @@ -205,17 +205,13 @@ export function getAppModelOptions( // see the user's authored custom models. const defaultInstanceId = defaultInstanceIdForDriver(provider); const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { - if (seen.has(slug)) { + for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { + if (seen.has(entry.slug)) { continue; } - seen.add(slug); - options.push({ - slug, - name: slug, - isCustom: true, - }); + seen.add(entry.slug); + options.push({ slug: entry.slug, name: entry.name, isCustom: true }); } const preferences = readInstanceModelPreferences(settings, defaultInstanceId); @@ -257,13 +253,13 @@ export function getAppModelOptionsForInstance( ); const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); - for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { - if (seen.has(slug)) { + for (const custom of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { + if (seen.has(custom.slug)) { continue; } - seen.add(slug); - options.push({ slug, name: slug, isCustom: true }); + seen.add(custom.slug); + options.push({ slug: custom.slug, name: custom.name, isCustom: true }); } const preferences = readInstanceModelPreferences(settings, entry.instanceId); diff --git a/docs/user/composer.md b/docs/user/composer.md index 740c44b8129d..2220fa53c0ec 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -30,6 +30,12 @@ messages while disconnected. Uploads resume when you reconnect. Drafts and queue messages survive app restarts. Signing out of T3 Connect keeps that work on your device until you sign back into the same account. +## Custom models + +On web and desktop, use Settings → Providers → **Models** to add an unlisted model with a custom +name and options. Only options supported by the provider integration affect turns. Antigravity +uses its account catalog and does not support custom models. + ## Model defaults T3 Code remembers your provider, model, and model options for new threads. A diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index d217a0cc2cbb..bce1a766bc9b 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -127,6 +127,22 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; +/** + * A user-authored custom model. `name` and `capabilities` are optional so a + * bare slug keeps its driver-default presentation; when `capabilities` is + * set, its descriptors replace the driver default in the model picker. + */ +export const CustomModelEntry = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: Schema.optional(TrimmedNonEmptyString), + capabilities: Schema.optional(ModelCapabilities), +}); +export type CustomModelEntry = typeof CustomModelEntry.Type; + +/** On-disk custom model setting: the legacy bare slug, or a full entry. */ +export const CustomModelSetting = Schema.Union([Schema.String, CustomModelEntry]); +export type CustomModelSetting = typeof CustomModelSetting.Type; + const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index bee5c9a3f0d5..c5d9c52a7175 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -73,6 +73,40 @@ describe("ServerSettings usage price overrides", () => { }); }); +describe("custom model settings", () => { + const capabilities = { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High", isDefault: true }], + }, + ], + }; + + it("accepts legacy bare slugs alongside full entries", () => { + const decoded = decodeClaudeSettings({ + customModels: ["bare-slug", { slug: "named", name: "Named", capabilities }], + }); + expect(decoded.customModels).toEqual([ + "bare-slug", + { slug: "named", name: "Named", capabilities }, + ]); + }); + + it("accepts entries at the settings patch boundary", () => { + expect( + decodeServerSettingsPatch({ + providers: { codex: { customModels: [{ slug: "x", capabilities }] } }, + }).providers?.codex?.customModels, + ).toEqual([{ slug: "x", capabilities }]); + expect(() => + decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }), + ).toThrow(); + }); +}); + describe("ClaudeSettings auto-compaction", () => { it("uses Claude's default threshold when no override is configured", () => { expect(decodeClaudeSettings({}).autoCompactWindow).toBe(""); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fbc812225c..cf68dcf62de8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -6,6 +6,7 @@ import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { + CustomModelSetting, DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, @@ -472,7 +473,7 @@ export const CodexSettings = makeProviderSettingsSchema( description: "Additional CLI arguments passed to codex app-server on session start.", }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -510,7 +511,7 @@ export const ClaudeSettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "~/.claude", clearWhenEmpty: "omit" }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -571,7 +572,7 @@ export const CursorSettings = makeProviderSettingsSchema( }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -597,7 +598,7 @@ export const GrokSettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "grok", clearWhenEmpty: "omit" }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -682,7 +683,7 @@ export const AntigravitySettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -732,7 +733,7 @@ export const OpenCodeSettings = makeProviderSettingsSchema( }, }), ), - customModels: Schema.Array(Schema.String).pipe( + customModels: Schema.Array(CustomModelSetting).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), @@ -1055,14 +1056,14 @@ const CodexSettingsPatch = Schema.Struct({ homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), launchArgs: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const ClaudeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), launchArgs: Schema.optionalKey(TrimmedString), // Validated at the patch boundary so a typo fails the one update with a // schema error instead of a generic whole-settings failure. @@ -1075,13 +1076,13 @@ const CursorSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), apiEndpoint: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const GrokSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const AntigravitySettingsPatch = Schema.Struct({ @@ -1091,7 +1092,7 @@ const AntigravitySettingsPatch = Schema.Struct({ gcpProject: Schema.optionalKey(TrimmedString), gcpLocation: Schema.optionalKey(TrimmedString), binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); const OpenCodeSettingsPatch = Schema.Struct({ @@ -1099,7 +1100,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), serverUrl: Schema.optionalKey(TrimmedString), serverPassword: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), + customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), }); export const ServerSettingsPatch = Schema.Struct({ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index da779442c9c7..d2c64e3d9458 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -10,6 +10,8 @@ import { getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, + readCustomModelEntries, + toCustomModelSetting, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "./model.ts"; @@ -192,3 +194,53 @@ describe("applyClaudePromptEffortPrefix", () => { ); }); }); + +describe("readCustomModelEntries", () => { + const capabilities: ModelCapabilities = { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High", isDefault: true }], + currentValue: "high", + }, + ], + }; + + it("resolves bare slugs and entries, trimming and deduplicating on slug", () => { + expect( + readCustomModelEntries([ + " bare ", + { slug: "named", name: " Named ", capabilities }, + "bare", + { slug: "named", name: "Second" }, + "", + { name: "no slug" }, + 42, + ]), + ).toEqual([ + { slug: "bare", name: "bare", capabilities: null }, + { slug: "named", name: "Named", capabilities }, + ]); + }); + + it("drops unparseable capabilities but keeps the entry", () => { + expect( + readCustomModelEntries([{ slug: "x", capabilities: { optionDescriptors: "nope" } }]), + ).toEqual([{ slug: "x", name: "x", capabilities: null }]); + expect(readCustomModelEntries("not a list")).toEqual([]); + }); + + it("writes the compact stored shape back", () => { + expect(toCustomModelSetting({ slug: "x", name: "x", capabilities: null })).toBe("x"); + expect( + toCustomModelSetting({ slug: "x", name: "x", capabilities: { optionDescriptors: [] } }), + ).toBe("x"); + expect(toCustomModelSetting({ slug: "x", name: "X", capabilities })).toEqual({ + slug: "x", + name: "X", + capabilities, + }); + }); +}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index cad7c8e8db86..940778fba1ea 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,12 +1,15 @@ import { + type CustomModelSetting, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ModelCapabilities, + ModelCapabilities, type ModelSelection, ProviderDriverKind, ProviderInstanceId, type ProviderOptionDescriptor, type ProviderOptionSelection, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -247,6 +250,76 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri return model.trim() || null; } +/** A custom model setting with its optional fields resolved. */ +export interface CustomModelDefinition { + readonly slug: string; + readonly name: string; + readonly capabilities: ModelCapabilities | null; +} + +const decodeCustomModelCapabilities = Schema.decodeUnknownOption(ModelCapabilities); + +/** + * Read a `customModels` setting into resolved definitions. Accepts the typed + * union as well as the opaque `providerInstances[id].config` blob clients see, + * so it tolerates bare slugs, malformed rows, and unparseable capabilities + * (dropped rather than failing the whole list). Slugs are trimmed and + * deduplicated, first occurrence wins; `name` falls back to the slug. + */ +export function readCustomModelEntries(value: unknown): CustomModelDefinition[] { + if (!Array.isArray(value)) return []; + const entries: CustomModelDefinition[] = []; + const seen = new Set(); + for (const raw of value) { + const record = + typeof raw === "string" + ? { slug: raw } + : raw !== null && typeof raw === "object" + ? (raw as { slug?: unknown; name?: unknown; capabilities?: unknown }) + : null; + if (!record) continue; + const slug = normalizeCustomModelSlug(typeof record.slug === "string" ? record.slug : null); + if (!slug || seen.has(slug)) continue; + seen.add(slug); + const name = + (typeof record.name === "string" ? normalizeCustomModelSlug(record.name) : null) ?? slug; + const capabilities = + record.capabilities === undefined || record.capabilities === null + ? null + : Option.getOrNull(decodeCustomModelCapabilities(record.capabilities)); + entries.push({ + slug, + name, + capabilities: capabilities + ? createModelCapabilities({ optionDescriptors: capabilities.optionDescriptors ?? [] }) + : null, + }); + } + return entries; +} + +/** Slugs of a `customModels` setting, in stored order. */ +export function readCustomModelSlugs(value: unknown): string[] { + return readCustomModelEntries(value).map((entry) => entry.slug); +} + +/** + * Write a definition back to the compact stored shape: a bare slug when it + * carries nothing custom, otherwise an entry with only the set fields. + */ +export function toCustomModelSetting(entry: CustomModelDefinition): CustomModelSetting { + const descriptors = entry.capabilities?.optionDescriptors ?? []; + const name = entry.name !== entry.slug ? entry.name : undefined; + if (!name && descriptors.length === 0) return entry.slug; + return { + slug: entry.slug, + ...(name ? { name } : {}), + ...(descriptors.length > 0 + ? { capabilities: createModelCapabilities({ optionDescriptors: descriptors }) } + : {}), + }; +} + export function resolveSelectableModel( provider: ProviderDriverKind, value: string | null | undefined, From 087cfb8ae262f344f7e409f5a8c0eda6f0ef12f9 Mon Sep 17 00:00:00 2001 From: Ezra Date: Sat, 5 Sep 2026 02:27:27 +0300 Subject: [PATCH 02/69] fix(cursor): discover symlinked skills as package boundaries (#9420) --- .../src/provider/Drivers/CursorSkills.ts | 23 +++++---- .../provider/Layers/CursorProvider.test.ts | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 5113fd3d0ca6..7b0637267c80 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -147,16 +147,18 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( if (!resolvedDirectory) { return; } - if ( - visitedDirectories.has(resolvedDirectory) || - (resolvedDirectory !== rootDirectory && - !resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`)) - ) { + if (visitedDirectories.has(resolvedDirectory)) { return; } visitedDirectories.add(resolvedDirectory); + // A symlink whose target lives outside the root is a skill package + // boundary: read its own SKILL.md so linked skill libraries show up, but + // never walk the target tree. + const insideRoot = + resolvedDirectory === rootDirectory || + resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); - const skillPath = path.join(resolvedDirectory, "SKILL.md"); + const skillPath = path.join(directory, "SKILL.md"); const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); if (skillInfo?.type === "File") { let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; @@ -167,7 +169,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( frontmatter = parseSkillFrontmatter(contents); } } - const name = path.basename(resolvedDirectory).trim(); + const name = path.basename(directory).trim(); if (frontmatter?.cliVisible && name) { skills.push({ name, @@ -184,7 +186,10 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( } } - const entries = yield* orUndefined(fileSystem.readDirectory(resolvedDirectory), input.budget); + if (!insideRoot) { + return; + } + const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); if (!entries) { return; } @@ -194,7 +199,7 @@ const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* ( return; } input.budget.remainingEntries -= 1; - const child = path.join(resolvedDirectory, entry); + const child = path.join(directory, entry); const info = yield* orUndefined(fileSystem.stat(child), input.budget); if (info?.type !== "Directory") continue; if (depth >= MAX_SKILL_DEPTH) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 8f1a0c54cf5a..a01958dcccac 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -395,6 +395,56 @@ describe("Cursor skills", () => { }), )); + it("treats a symlinked skill outside the root as a package boundary", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }); + const workspace = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }); + const library = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-library-", + }); + const writeSkill = Effect.fn("writeCursorSkill")(function* ( + directory: string, + contents: string, + ) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); + }); + + // A skill package managed in a config repo and installed by symlink. + // Its own SKILL.md must be discovered under the link name, but nothing + // below the target may be walked. + yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); + yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); + const root = path.join(workspace, ".cursor", "skills"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "review", + description: "shared", + path: path.join(root, "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Success"); + }), + )); + it("rewrites only discovered skill mentions into Cursor slash invocations", () => { expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); expect(hasCursorSkillMention("please $review this")).toBe(true); From f33fdc992488e36ccb70cbb71d55e630b5184cbd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 16:43:45 -0700 Subject: [PATCH 03/69] fix(ssh): exec managed servers without npm wrappers (#9843) --- packages/ssh/src/runnerProcess.test.ts | 167 +++++++++++++++++++++++++ packages/ssh/src/tunnel.test.ts | 5 +- packages/ssh/src/tunnel.ts | 5 +- 3 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 packages/ssh/src/runnerProcess.test.ts diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts new file mode 100644 index 000000000000..7dda675ee95c --- /dev/null +++ b/packages/ssh/src/runnerProcess.test.ts @@ -0,0 +1,167 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeNet from "node:net"; + +import { buildRemoteT3RunnerScript } from "./tunnel.ts"; + +const Started = Schema.Struct({ + pid: Schema.Number, + port: Schema.Number, + args: Schema.Array(Schema.String), +}); +const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner process ownership", + () => { + it.live.each(["npx", "npm"] as const)( + "keeps the server PID and graceful shutdown through the %s fallback", + (packageManager) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "package-manager-calls.jsonl"); + const packageSpec = "t3@0.0.35"; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +import * as net from "node:net"; +const server = net.createServer((socket) => { + socket.end(); + server.close(); +}); +process.on("SIGTERM", () => server.close(() => { + process.stdout.write("graceful shutdown\\n"); +})); +server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { + process.stdout.write(JSON.stringify({ + pid: process.pid, + port: server.address().port, + args: process.argv.slice(2), + }) + "\\n"); +}); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +const childProcess = require("node:child_process"); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n"); +if (args.includes("--package")) { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} else { + const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" }); + child.once("exit", (code) => { process.exitCode = code ?? 1; }); +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + + const runServer = (port = 0) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { + cwd: fixture, + env: { + PATH: bin, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + T3_TEST_PORT: String(port), + }, + detached: false, + stdin: Stream.make( + new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })), + ), + }), + ); + const ready = yield* Deferred.make(); + const stdout: string[] = []; + const output = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => + Effect.gen(function* () { + stdout.push(line); + if (stdout.length === 1) { + yield* Deferred.succeed(ready, decodeStarted(line)); + } + }), + ), + Effect.forkScoped, + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkScoped, + ); + const receipt = yield* Effect.raceFirst( + Deferred.await(ready), + Fiber.join(output).pipe( + Effect.flatMap(() => Fiber.join(stderr)), + Effect.flatMap((message) => + Effect.die(new Error(`Runner exited before listening: ${message}`)), + ), + ), + ); + // A failed PID assertion must still close the owned fixture server, including an npm child. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* child.isRunning) { + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(receipt.port, "127.0.0.1"); + connection.on("error", () => undefined); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + yield* child.exitCode; + } + }).pipe(Effect.orDie), + ); + assert.equal(receipt.pid, child.pid); + assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); + yield* child.kill({ killSignal: "SIGTERM" }); + assert.equal(yield* child.exitCode, 0); + yield* Fiber.join(output); + assert.include(stdout, "graceful shutdown"); + return receipt.port; + }).pipe(Effect.scoped); + + const port = yield* runServer(); + assert.equal(yield* runServer(port), port); + const calls = (yield* fs.readFileString(callsPath)) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + assert.deepEqual(calls, [expectedCall, expectedCall]); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 392885b640c1..4a49cacc2eb8 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -105,8 +105,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include(script, 'exec t3 "$@"'); - assert.include(script, "exec npx --yes 't3@latest' \"$@\""); - assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); + assert.include(script, 'exec "$T3_CLI_PATH" "$@"'); assert.include(script, "could not install 't3@latest'"); assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); @@ -141,8 +140,6 @@ describe("ssh tunnel scripts", () => { packageSpec: "t3@nightly; touch /tmp/t3-owned", }); - assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\""); - assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\""); assert.include( script, "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 0afd1007f8ed..04dbce65af60 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -440,13 +440,14 @@ require_installed_t3_cli() { printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 return 1 } +# The launcher records this PID, so exec the CLI without an npm wrapper process. if command -v npx >/dev/null 2>&1; then require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec npx --yes @@T3_PACKAGE_SPEC@@ "$@" + exec "$T3_CLI_PATH" "$@" fi if command -v npm >/dev/null 2>&1; then require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" + exec "$T3_CLI_PATH" "$@" fi printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 exit 1 From 0dd5c64bcfac7974d81bac76740ecd83540077e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 16:45:00 -0700 Subject: [PATCH 04/69] fix(server): detect nested Git workspaces for checkpoints (#9842) --- apps/server/src/git/Utils.ts | 7 -- .../Layers/CheckpointReactor.test.ts | 77 +++++++++++++++++++ .../orchestration/Layers/CheckpointReactor.ts | 9 +-- .../Layers/ProviderRuntimeIngestion.test.ts | 43 ++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 10 ++- 5 files changed, 129 insertions(+), 17 deletions(-) delete mode 100644 apps/server/src/git/Utils.ts diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts deleted file mode 100644 index e4a703f44540..000000000000 --- a/apps/server/src/git/Utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; - -export function isGitRepository(cwd: string): boolean { - return NodeFS.existsSync(NodePath.join(cwd, ".git")); -} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index a01a938187f2..2f2c4b30525b 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -590,6 +590,83 @@ describe("CheckpointReactor", () => { }), ); + effectIt.effect("captures and reverts checkpoints from a nested Git workspace", () => + Effect.gen(function* () { + const repositoryRoot = createGitRepository(); + tempDirs.push(repositoryRoot); + const workspaceRoot = NodePath.join(repositoryRoot, "apps", "server"); + NodeFS.mkdirSync(workspaceRoot, { recursive: true }); + const filePath = NodePath.join(workspaceRoot, "index.ts"); + NodeFS.writeFileSync(filePath, "export const value = 1;\n"); + runGit(repositoryRoot, ["add", "."]); + runGit(repositoryRoot, ["commit", "-m", "Add nested workspace"]); + const harness = yield* Effect.promise(() => + createHarness({ + seedFilesystemCheckpoints: false, + projectWorkspaceRoot: workspaceRoot, + threadWorktreePath: workspaceRoot, + providerSessionCwd: workspaceRoot, + }), + ); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-nested"); + const createdAt = "2026-01-01T00:00:00.000Z"; + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-nested-start"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + }); + yield* Effect.promise(harness.drain); + expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 0))).toBe(true); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + }); + + NodeFS.writeFileSync(filePath, "export const value = 2;\n"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-nested-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + payload: { state: "completed" }, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.checkpoints[0]).toMatchObject({ + status: "ready", + files: [{ path: "apps/server/index.ts", additions: 1, deletions: 1 }], + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.make("cmd-nested-revert"), + threadId, + turnCount: 0, + createdAt, + }); + yield* Effect.promise(harness.drain); + expect(NodeFS.readFileSync(filePath, "utf8")).toBe("export const value = 1;\n"); + expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ threadId, numTurns: 1 }); + expect(gitRefExists(repositoryRoot, checkpointRefForThreadTurn(threadId, 1))).toBe(false); + const reverted = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect(reverted?.checkpoints).toEqual([]); + }), + ); + it("refreshes local git status state on turn completion using the session cwd", async () => { const gitStatusRefreshCalls: string[] = []; const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0a56eb840960..108abd5d06bb 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -35,7 +35,6 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; -import { isGitRepository } from "../../git/Utils.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; @@ -181,8 +180,6 @@ const make = Effect.gen(function* () { return project ? [project] : []; }); - const isGitWorkspace = (cwd: string) => isGitRepository(cwd); - // Resolves the workspace CWD for checkpoint operations, preferring the // active provider session CWD and falling back to the thread/project config. // Returns undefined when no CWD can be determined or the workspace is not @@ -192,7 +189,7 @@ const make = Effect.gen(function* () { readonly thread: { readonly projectId: ProjectId; readonly worktreePath: string | null }; readonly projects: ReadonlyArray<{ readonly id: ProjectId; readonly workspaceRoot: string }>; readonly preferSessionRuntime: boolean; - }): Effect.fn.Return { + }): Effect.fn.Return { const fromSession = yield* resolveSessionRuntimeForThread(input.threadId); const fromThread = resolveThreadWorkspaceCwd({ thread: input.thread, @@ -213,7 +210,7 @@ const make = Effect.gen(function* () { if (!cwd) { return undefined; } - if (!isGitWorkspace(cwd)) { + if (!(yield* checkpointStore.isGitRepository(cwd))) { return undefined; } return cwd; @@ -751,7 +748,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } - if (!isGitWorkspace(sessionRuntime.value.cwd)) { + if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, turnCount: event.payload.turnCount, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 923e8c16f540..827e14734dc6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; import { OrchestrationReadModel, @@ -44,6 +45,9 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -257,9 +261,15 @@ describe("ProviderRuntimeIngestion", () => { async function createHarness(options?: { serverSettings?: Partial; threadTitle?: string; + workspaceSubdirectory?: string; }) { - const workspaceRoot = makeTempDir("t3-provider-project-"); - NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); + const repositoryRoot = makeTempDir("t3-provider-project-"); + NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], { + cwd: repositoryRoot, + stdio: "ignore", + }); + const workspaceRoot = NodePath.join(repositoryRoot, options?.workspaceSubdirectory ?? ""); + NodeFS.mkdirSync(workspaceRoot, { recursive: true }); const provider = createProviderServiceHarness(); const sqlCounter = makeSqlStatementCounter(); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -284,6 +294,8 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), + Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)), @@ -3177,6 +3189,33 @@ describe("ProviderRuntimeIngestion", () => { }); }); + effectIt.effect("tracks provider diff updates from a nested Git workspace", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ workspaceSubdirectory: "apps/server" }), + ); + yield* Effect.promise(() => + harness.emitAndDrain([ + { + type: "turn.diff.updated", + eventId: asEventId("evt-nested-diff"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("nested-turn"), + payload: { + unifiedDiff: "diff --git a/apps/server/file.ts b/apps/server/file.ts\n+new\n", + }, + }, + ]), + ); + const snapshot = yield* Effect.promise(harness.readModel); + expect(snapshot.threads[0]?.checkpoints).toEqual([ + expect.objectContaining({ turnId: "nested-turn", status: "missing" }), + ]); + }), + ); + it("consumes P1 runtime events into thread metadata, diff checkpoints, and activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 449123d2e9da..a9be5d5b6e1f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -36,7 +36,7 @@ import { formatTokens } from "@t3tools/shared/usageFormat"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; -import { isGitRepository } from "../../git/Utils.ts"; +import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; @@ -956,6 +956,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; + const checkpointStore = yield* CheckpointStore.CheckpointStore; const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), @@ -2013,7 +2014,12 @@ const make = Effect.gen(function* () { : undefined; const workspaceCwd = checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined; - if (turnId && checkpointContext && workspaceCwd && isGitRepository(workspaceCwd)) { + if ( + turnId && + checkpointContext && + workspaceCwd && + (yield* checkpointStore.isGitRepository(workspaceCwd)) + ) { // Skip if a checkpoint already exists for this turn. A real // (non-placeholder) capture from CheckpointReactor should not // be clobbered, and dispatching a duplicate placeholder for the From 4f1092cec2ebaa6ed0e49821238dfe6f1add362d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:04:54 -0700 Subject: [PATCH 05/69] fix(web): keep worktree origin preference visible (#9846) --- .../components/settings/SettingsPanels.tsx | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 66f7691a4a07..8482e37440a4 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2601,37 +2601,35 @@ export function GeneralSettingsPanel() { } /> - {settings.defaultThreadEnvMode === "worktree" ? ( - - updateSettings({ - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } - control={ - - updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) + + updateSettings({ + newWorktreesStartFromOrigin: + DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + }) } - aria-label="Start new worktrees from origin by default" /> - } - /> - ) : null} + ) : null + } + control={ + + updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) + } + aria-label="Start new worktrees from origin by default" + /> + } + /> Date: Fri, 4 Sep 2026 17:06:27 -0700 Subject: [PATCH 06/69] fix(web): smooth settings sidebar transitions (#9811) --- .../settings/SettingsSidebarNav.tsx | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 2716569c571c..4f851c9b8044 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -8,6 +8,7 @@ import { useState, type ComponentType, type KeyboardEvent, + type ReactNode, } from "react"; import { ArchiveIcon, @@ -24,6 +25,7 @@ import { import { useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel } from "../ui/collapsible"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; import { @@ -114,6 +116,22 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) { return ; } +function SettingsSubmenuCollapse({ + open, + children, +}: { + readonly open: boolean; + readonly children: ReactNode; +}) { + return ( + + + {children} + + + ); +} + export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); @@ -125,6 +143,9 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; + const activeSettingsPath = SETTINGS_NAV_ITEMS.find( + (item) => pathname === item.to || pathname.startsWith(`${item.to}/`), + )?.to; useEffect(() => { setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); @@ -347,8 +368,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { {SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; - const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); const pageSections = SETTINGS_PAGE_SECTIONS[item.to]; + const isActive = activeSettingsPath === item.to; return ( {item.label} - {isActive && pageSections ? ( - - {pageSections.map((section) => ( - - } - size="sm" - className="w-full text-sidebar-muted-foreground/65" - onClick={() => handlePageSectionClick(item.to, section.targetId)} - > - {section.label} - - - ))} - + {pageSections ? ( + + + {pageSections.map((section) => ( + + } + size="sm" + className="w-full text-sidebar-muted-foreground/65" + onClick={() => handlePageSectionClick(item.to, section.targetId)} + > + {section.label} + + + ))} + + ) : null} ); From 4d3907f63d71644e1918d76cd104b036535a1173 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:06:27 -0700 Subject: [PATCH 07/69] feat(web): highlight visible settings sections (#9812) --- .../settings/SettingsSidebarNav.tsx | 49 +++- .../settingsSectionVisibility.test.ts | 245 ++++++++++++++++++ .../settings/settingsSectionVisibility.ts | 186 +++++++++++++ apps/web/src/routes/settings.tsx | 5 +- 4 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/settings/settingsSectionVisibility.test.ts create mode 100644 apps/web/src/components/settings/settingsSectionVisibility.ts diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 4f851c9b8044..f8f0254cda61 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -22,12 +22,13 @@ import { Settings2Icon, XIcon, } from "lucide-react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useRouterState } from "@tanstack/react-router"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel } from "../ui/collapsible"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; +import { cn } from "../../lib/utils"; import { SidebarContent, SidebarFooter, @@ -42,6 +43,11 @@ import { } from "../ui/sidebar"; import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; +import { + getVisibleSettingsSectionIds, + observeSettingsSectionVisibility, + type SettingsSectionVisibilityState, +} from "./settingsSectionVisibility"; import { searchSettings, SETTINGS_SECTION_LABELS, @@ -99,6 +105,7 @@ const SETTINGS_PAGE_SECTIONS: Partial< "/settings/appearance": [ { label: "Colors & themes", targetId: "appearance" }, { label: "Interface", targetId: "appearance-interface" }, + { label: "Motion", targetId: "motion" }, { label: "Typography", targetId: "typography" }, ], "/settings/source-control": [ @@ -135,10 +142,16 @@ function SettingsSubmenuCollapse({ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); + const resolvedPathname = useRouterState({ + select: (state) => state.resolvedLocation?.pathname, + }); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); + const [sectionVisibility, setSectionVisibility] = useState( + null, + ); const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; @@ -146,6 +159,33 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const activeSettingsPath = SETTINGS_NAV_ITEMS.find( (item) => pathname === item.to || pathname.startsWith(`${item.to}/`), )?.to; + const observedVisibilityScope = useMemo(() => { + const path = SETTINGS_NAV_ITEMS.find( + (item) => + resolvedPathname === item.to || resolvedPathname?.startsWith(`${item.to}/`) === true, + )?.to; + const pageSections = path ? SETTINGS_PAGE_SECTIONS[path] : undefined; + return path && pageSections ? { path, pageSections } : null; + }, [resolvedPathname]); + const visiblePageSectionIds = getVisibleSettingsSectionIds({ + activePath: activeSettingsPath, + scope: observedVisibilityScope, + visibility: sectionVisibility, + }); + + useEffect(() => { + if (!observedVisibilityScope) return; + const container = document.querySelector("[data-settings-page-layout]"); + if (!container) return; + + return observeSettingsSectionVisibility({ + container, + targetIds: observedVisibilityScope.pageSections.map((section) => section.targetId), + onChange(targetIds) { + setSectionVisibility({ scope: observedVisibilityScope, targetIds: new Set(targetIds) }); + }, + }); + }, [observedVisibilityScope]); useEffect(() => { setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); @@ -387,7 +427,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { } size="sm" - className="w-full text-sidebar-muted-foreground/65" + data-visible={visiblePageSectionIds.has(section.targetId)} + className={cn( + "w-full text-sidebar-muted-foreground/65", + visiblePageSectionIds.has(section.targetId) && + "font-medium text-sidebar-foreground", + )} onClick={() => handlePageSectionClick(item.to, section.targetId)} > {section.label} diff --git a/apps/web/src/components/settings/settingsSectionVisibility.test.ts b/apps/web/src/components/settings/settingsSectionVisibility.test.ts new file mode 100644 index 000000000000..90656ad7073a --- /dev/null +++ b/apps/web/src/components/settings/settingsSectionVisibility.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + getVisibleSettingsSectionIds, + observeSettingsSectionVisibility, + type SettingsSectionVisibilityEnvironment, +} from "./settingsSectionVisibility"; + +type VisibilityEntry = Pick< + IntersectionObserverEntry, + "intersectionRatio" | "isIntersecting" | "target" +>; + +function createHarness( + targetIds: ReadonlyArray, + initialRoot: Element | null = { name: "initial-root" } as unknown as Element, +) { + const targets = new Map( + targetIds.map((targetId) => [targetId, { targetId } as unknown as Element]), + ); + const observed = new Set(); + const unobserve = vi.fn((target: Element) => observed.delete(target)); + const disconnectIntersections = vi.fn(); + const disconnectMutations = vi.fn(); + const intersectionCallbacks: Array<(entries: ReadonlyArray) => void> = []; + const intersectionRoots: Element[] = []; + let root = initialRoot; + let onMutation = () => {}; + + const environment: SettingsSectionVisibilityEnvironment = { + findRoot: () => root, + findTarget: (_root, targetId) => targets.get(targetId) ?? null, + createIntersectionObserver(callback, observedRoot) { + intersectionCallbacks.push(callback); + intersectionRoots.push(observedRoot); + return { + observe: (target) => observed.add(target), + unobserve, + disconnect: disconnectIntersections, + }; + }, + createMutationObserver(callback) { + onMutation = callback; + return { disconnect: disconnectMutations }; + }, + }; + + return { + environment, + targets, + observed, + unobserve, + disconnectIntersections, + disconnectMutations, + intersectionCallbacks, + intersectionRoots, + intersect(entries: ReadonlyArray) { + intersectionCallbacks.at(-1)?.(entries); + }, + mutate() { + onMutation(); + }, + replaceRoot(nextRoot: Element | null) { + root = nextRoot; + onMutation(); + }, + }; +} + +function visibleEntry( + target: Element, + { isIntersecting = true, intersectionRatio = 1 } = {}, +): VisibilityEntry { + return { target, isIntersecting, intersectionRatio }; +} + +describe("settings section visibility", () => { + it("does not reuse visibility when returning to the same sectioned route", () => { + const firstGeneralVisit = { path: "/settings/general" }; + const firstVisibility = { + scope: firstGeneralVisit, + targetIds: new Set(["text-generation"]), + }; + + expect( + getVisibleSettingsSectionIds({ + activePath: "/settings/general", + scope: firstGeneralVisit, + visibility: firstVisibility, + }), + ).toEqual(new Set(["text-generation"])); + expect( + getVisibleSettingsSectionIds({ + activePath: "/settings/providers", + scope: null, + visibility: firstVisibility, + }), + ).toEqual(new Set()); + + const secondGeneralVisit = { path: "/settings/general" }; + expect( + getVisibleSettingsSectionIds({ + activePath: "/settings/general", + scope: secondGeneralVisit, + visibility: firstVisibility, + }), + ).toEqual(new Set()); + }); + + it("accumulates visible sections and emits them in sidebar order", () => { + const harness = createHarness(["one", "two", "three"]); + const emissions: ReadonlyArray[] = []; + observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["one", "two", "three"], + onChange: (visible) => emissions.push(visible), + environment: harness.environment, + }); + + harness.intersect([visibleEntry(harness.targets.get("two")!)]); + harness.intersect([visibleEntry(harness.targets.get("one")!)]); + + expect(emissions).toEqual([[], ["two"], ["one", "two"]]); + }); + + it("treats zero-ratio and non-intersecting entries as hidden", () => { + const harness = createHarness(["one", "two"]); + const emissions: ReadonlyArray[] = []; + observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["one", "two"], + onChange: (visible) => emissions.push(visible), + environment: harness.environment, + }); + + const one = harness.targets.get("one")!; + const two = harness.targets.get("two")!; + harness.intersect([visibleEntry(one), visibleEntry(two)]); + harness.intersect([visibleEntry(one, { intersectionRatio: 0 })]); + harness.intersect([visibleEntry(two, { isIntersecting: false })]); + + expect(emissions).toEqual([[], ["one", "two"], ["two"], []]); + }); + + it("resyncs replaced and removed targets without retaining stale visibility", () => { + const harness = createHarness(["dynamic"]); + const emissions: ReadonlyArray[] = []; + observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["dynamic"], + onChange: (visible) => emissions.push(visible), + environment: harness.environment, + }); + + const firstTarget = harness.targets.get("dynamic")!; + harness.intersect([visibleEntry(firstTarget)]); + const replacementTarget = { targetId: "dynamic-replacement" } as unknown as Element; + harness.targets.set("dynamic", replacementTarget); + harness.mutate(); + + expect(harness.unobserve).toHaveBeenCalledWith(firstTarget); + expect(harness.observed.has(replacementTarget)).toBe(true); + expect(emissions.at(-1)).toEqual([]); + + harness.intersect([visibleEntry(firstTarget)]); + expect(emissions.at(-1)).toEqual([]); + harness.intersect([visibleEntry(replacementTarget)]); + expect(emissions.at(-1)).toEqual(["dynamic"]); + + harness.targets.delete("dynamic"); + harness.mutate(); + expect(harness.unobserve).toHaveBeenCalledWith(replacementTarget); + expect(emissions.at(-1)).toEqual([]); + }); + + it("rebinds when navigation replaces the settings scroll root", () => { + const harness = createHarness(["section"]); + const emissions: ReadonlyArray[] = []; + observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["section"], + onChange: (visible) => emissions.push(visible), + environment: harness.environment, + }); + + const firstTarget = harness.targets.get("section")!; + harness.intersect([visibleEntry(firstTarget)]); + const firstObserverCallback = harness.intersectionCallbacks[0]!; + const nextRoot = { name: "next-root" } as unknown as Element; + const nextTarget = { targetId: "next-section" } as unknown as Element; + harness.targets.set("section", nextTarget); + harness.replaceRoot(nextRoot); + + expect(harness.disconnectIntersections).toHaveBeenCalledOnce(); + expect(harness.intersectionRoots.at(-1)).toBe(nextRoot); + expect(harness.observed.has(nextTarget)).toBe(true); + expect(emissions.at(-1)).toEqual([]); + + firstObserverCallback([visibleEntry(firstTarget)]); + expect(emissions.at(-1)).toEqual([]); + harness.intersect([visibleEntry(nextTarget)]); + expect(emissions.at(-1)).toEqual(["section"]); + }); + + it("starts observing when the settings scroll root mounts later", () => { + const harness = createHarness(["section"], null); + const emissions: ReadonlyArray[] = []; + observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["section"], + onChange: (visible) => emissions.push(visible), + environment: harness.environment, + }); + + expect(emissions).toEqual([[]]); + expect(harness.intersectionRoots).toEqual([]); + + const root = { name: "mounted-root" } as unknown as Element; + harness.replaceRoot(root); + harness.intersect([visibleEntry(harness.targets.get("section")!)]); + + expect(harness.intersectionRoots).toEqual([root]); + expect(emissions.at(-1)).toEqual(["section"]); + }); + + it("disconnects both observers and ignores callbacks after cleanup", () => { + const harness = createHarness(["one"]); + const onChange = vi.fn(); + const cleanup = observeSettingsSectionVisibility({ + container: {} as Element, + targetIds: ["one"], + onChange, + environment: harness.environment, + }); + + cleanup(); + harness.intersect([visibleEntry(harness.targets.get("one")!)]); + harness.mutate(); + + expect(harness.disconnectIntersections).toHaveBeenCalledOnce(); + expect(harness.disconnectMutations).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith([]); + }); +}); diff --git a/apps/web/src/components/settings/settingsSectionVisibility.ts b/apps/web/src/components/settings/settingsSectionVisibility.ts new file mode 100644 index 000000000000..76b1fc943256 --- /dev/null +++ b/apps/web/src/components/settings/settingsSectionVisibility.ts @@ -0,0 +1,186 @@ +type VisibilityEntry = Pick< + IntersectionObserverEntry, + "intersectionRatio" | "isIntersecting" | "target" +>; + +export type SettingsSectionVisibilityScope = { + readonly path: string; +}; + +export type SettingsSectionVisibilityState = { + readonly scope: SettingsSectionVisibilityScope; + readonly targetIds: ReadonlySet; +}; + +const EMPTY_VISIBLE_SETTINGS_SECTION_IDS: ReadonlySet = new Set(); + +export function getVisibleSettingsSectionIds({ + activePath, + scope, + visibility, +}: { + readonly activePath: string | undefined; + readonly scope: SettingsSectionVisibilityScope | null; + readonly visibility: SettingsSectionVisibilityState | null; +}): ReadonlySet { + if (!scope || activePath !== scope.path || visibility?.scope !== scope) { + return EMPTY_VISIBLE_SETTINGS_SECTION_IDS; + } + return visibility.targetIds; +} + +type ElementObserver = { + observe(target: Element): void; + unobserve(target: Element): void; + disconnect(): void; +}; + +type MutationSubscription = { + disconnect(): void; +}; + +export type SettingsSectionVisibilityEnvironment = { + findRoot(container: Element): Element | null; + findTarget(root: Element, targetId: string): Element | null; + createIntersectionObserver( + onEntries: (entries: ReadonlyArray) => void, + root: Element, + ): ElementObserver; + createMutationObserver(onMutation: () => void, container: Element): MutationSubscription; +}; + +function createBrowserEnvironment(): SettingsSectionVisibilityEnvironment { + return { + findRoot(container) { + return container.querySelector("[data-settings-page-scroll]"); + }, + findTarget(root, targetId) { + const target = root.ownerDocument.getElementById(targetId); + return target && root.contains(target) ? target : null; + }, + createIntersectionObserver(onEntries, scrollRoot) { + const observer = new IntersectionObserver(onEntries, { + root: scrollRoot, + threshold: 0, + }); + return observer; + }, + createMutationObserver(onMutation, container) { + const observer = new MutationObserver(onMutation); + observer.observe(container, { childList: true, subtree: true }); + return observer; + }, + }; +} + +export function observeSettingsSectionVisibility({ + container, + targetIds, + onChange, + environment = createBrowserEnvironment(), +}: { + readonly container: Element; + readonly targetIds: ReadonlyArray; + readonly onChange: (visibleTargetIds: ReadonlyArray) => void; + readonly environment?: SettingsSectionVisibilityEnvironment; +}): () => void { + const orderedTargetIds = [...new Set(targetIds)]; + const targetsById = new Map(); + const targetIdsByElement = new Map(); + const visibleTargetIds = new Set(); + let lastEmission: string | null = null; + let stopped = false; + let root: Element | null = null; + let intersectionObserver: ElementObserver | null = null; + let observerGeneration = 0; + + const emit = () => { + const visibleInOrder = orderedTargetIds.filter((targetId) => visibleTargetIds.has(targetId)); + const emissionKey = visibleInOrder.join("\0"); + if (emissionKey === lastEmission) return; + lastEmission = emissionKey; + onChange(visibleInOrder); + }; + + const handleEntries = (entries: ReadonlyArray, generation: number) => { + if (stopped || generation !== observerGeneration) return; + let changed = false; + for (const entry of entries) { + const targetId = targetIdsByElement.get(entry.target); + if (!targetId || targetsById.get(targetId) !== entry.target) continue; + const visible = entry.isIntersecting && entry.intersectionRatio > 0; + if (visible === visibleTargetIds.has(targetId)) continue; + changed = true; + if (visible) { + visibleTargetIds.add(targetId); + } else { + visibleTargetIds.delete(targetId); + } + } + if (changed) emit(); + }; + + const syncTargets = () => { + if (stopped) return; + let changed = false; + const nextRoot = environment.findRoot(container); + + if (nextRoot !== root) { + observerGeneration += 1; + intersectionObserver?.disconnect(); + intersectionObserver = null; + root = nextRoot; + targetsById.clear(); + targetIdsByElement.clear(); + changed = visibleTargetIds.size > 0; + visibleTargetIds.clear(); + + if (root) { + const generation = observerGeneration; + intersectionObserver = environment.createIntersectionObserver( + (entries) => handleEntries(entries, generation), + root, + ); + } + } + + if (!root || !intersectionObserver) { + if (changed) emit(); + return; + } + + for (const targetId of orderedTargetIds) { + const previousTarget = targetsById.get(targetId) ?? null; + const nextTarget = environment.findTarget(root, targetId); + if (previousTarget === nextTarget) continue; + + if (previousTarget) { + intersectionObserver.unobserve(previousTarget); + targetsById.delete(targetId); + targetIdsByElement.delete(previousTarget); + changed = visibleTargetIds.delete(targetId) || changed; + } + if (nextTarget) { + targetsById.set(targetId, nextTarget); + targetIdsByElement.set(nextTarget, targetId); + intersectionObserver.observe(nextTarget); + } + } + + if (changed) emit(); + }; + + const mutationObserver = environment.createMutationObserver(syncTargets, container); + syncTargets(); + emit(); + + return () => { + stopped = true; + observerGeneration += 1; + intersectionObserver?.disconnect(); + mutationObserver.disconnect(); + targetsById.clear(); + targetIdsByElement.clear(); + visibleTargetIds.clear(); + }; +} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 5e921fca5c2c..696ad200f3b7 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -69,7 +69,10 @@ function SettingsContentLayout() { }, [navigateBackWithinApp]); return ( - +
From 1963ca0abe07f9b9aac67820bca45ea3b46ce023 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:19:36 +0200 Subject: [PATCH 08/69] fix(web): keep the sidebar project filter across navigation (#9416) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 20 ++++- apps/web/src/hostedPairing.ts | 5 +- apps/web/src/state/entities.ts | 9 +- apps/web/src/state/shell.test.ts | 135 ++++++++++++++++++++++++++++ apps/web/src/state/shell.ts | 38 ++++++++ apps/web/src/uiStateStore.test.ts | 25 ++++++ apps/web/src/uiStateStore.ts | 32 +++++-- 7 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/state/shell.test.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4509fde6ceb9..2902e238e01e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -111,7 +111,11 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { + useAllEnvironmentProjectSnapshotsReady, + useProjects, + useThreadShells, +} from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; @@ -2098,7 +2102,11 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. - const [projectScopeKey, setProjectScopeKey] = useState(null); + // The selection lives in the persisted UI store next to the other sidebar + // project preferences, so routes that unmount the sidebar (Settings) and + // app restarts keep it. + const projectScopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); + const setProjectScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); // {value, label} items let Base UI drive the combobox selection contract // while the popup search filters the same collection. const projectScopeItems = useMemo( @@ -2162,11 +2170,15 @@ export default function Sidebar() { ), [scopedProjectGroup], ); + // A persisted scope whose project is gone falls back to all projects, but + // only after every catalog environment has a live project snapshot. Cached + // or disconnected environments cannot establish that the project is gone. + const allProjectSnapshotsReady = useAllEnvironmentProjectSnapshotsReady(); useEffect(() => { - if (projectScopeKey !== null && scopedProjectGroup === null) { + if (projectScopeKey !== null && allProjectSnapshotsReady && scopedProjectGroup === null) { setProjectScopeKey(null); } - }, [projectScopeKey, scopedProjectGroup]); + }, [allProjectSnapshotsReady, projectScopeKey, scopedProjectGroup, setProjectScopeKey]); // Count-only subscription: the parent needs "are there draft rows" for the // empty state, while SidebarDraftBlock owns the per-keystroke content // subscription. Selecting a number keeps typing in a draft composer from diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 6c5ac58cb35d..87c352244e5f 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -40,8 +40,9 @@ export function isHostedStaticApp(url?: URL): boolean { return true; } - // No window (tests, static render) means no origin to be hosted at. - if (url === undefined && typeof window === "undefined") { + // No window, or a window without a location (tests, static render), means + // no origin to be hosted at. + if (url === undefined && (typeof window === "undefined" || window.location === undefined)) { return false; } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index ec1e4c836211..c44c5b437b63 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -15,7 +15,10 @@ import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; import { environmentServerConfigsAtom } from "./server"; -import { allEnvironmentShellsBootstrappedAtom } from "./shell"; +import { + allEnvironmentProjectSnapshotsReadyAtom, + allEnvironmentShellsBootstrappedAtom, +} from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; const EMPTY_THREAD_REFS: ReadonlyArray = Object.freeze([]); @@ -79,6 +82,10 @@ export function useAllEnvironmentShellsBootstrapped(): boolean { return useAtomValue(allEnvironmentShellsBootstrappedAtom); } +export function useAllEnvironmentProjectSnapshotsReady(): boolean { + return useAtomValue(allEnvironmentProjectSnapshotsReadyAtom); +} + export function useThreadShellsForProjectRefs( refs: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/state/shell.test.ts b/apps/web/src/state/shell.test.ts new file mode 100644 index 000000000000..745e674400d3 --- /dev/null +++ b/apps/web/src/state/shell.test.ts @@ -0,0 +1,135 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentShellState } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createAllEnvironmentProjectSnapshotsReadyAtom } from "./shell"; + +const LOCAL = EnvironmentId.make("local"); +const REMOTE = EnvironmentId.make("remote"); + +function shellState(status: EnvironmentShellState["status"]): EnvironmentShellState { + return { + status, + snapshot: + status === "empty" + ? Option.none() + : Option.some({ + snapshotSequence: 1, + updatedAt: "2026-09-04T00:00:00.000Z", + projects: [], + threads: [], + }), + error: Option.none(), + }; +} + +function catalogState(environmentIds: readonly EnvironmentId[]): EnvironmentCatalogState { + return { + isReady: true, + entries: new Map( + environmentIds.map((environmentId) => [ + environmentId, + { + target: + environmentId === LOCAL + ? new PrimaryConnectionTarget({ + environmentId, + label: environmentId, + httpBaseUrl: `https://${environmentId}.example.test`, + wsBaseUrl: `wss://${environmentId}.example.test`, + }) + : new BearerConnectionTarget({ + environmentId, + connectionId: environmentId, + label: environmentId, + }), + profile: Option.none(), + }, + ]), + ), + }; +} + +function makeHarness(requiresPrimaryEnvironment = true) { + const catalog = Atom.make({ isReady: false, entries: new Map() }); + const shells = Atom.family((_environmentId: EnvironmentId) => + Atom.make(shellState("empty")), + ); + const ready = createAllEnvironmentProjectSnapshotsReadyAtom({ + catalogValueAtom: catalog, + shellStateValueAtom: shells, + requiresPrimaryEnvironment, + }); + const registry = AtomRegistry.make(); + return { catalog, shells, ready, registry }; +} + +describe("project snapshot readiness", () => { + it("does not clear a saved scope while the ready catalog is still empty", () => { + const { catalog, ready, registry } = makeHarness(); + expect(registry.get(ready)).toBe(false); + registry.set(catalog, catalogState([])); + expect(registry.get(ready)).toBe(false); + registry.dispose(); + }); + + it("waits for primary discovery even if a persisted remote is already live", () => { + const { catalog, shells, ready, registry } = makeHarness(); + registry.set(catalog, catalogState([REMOTE])); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(ready)).toBe(false); + + registry.set(catalog, catalogState([LOCAL, REMOTE])); + expect(registry.get(ready)).toBe(false); + registry.set(shells(LOCAL), shellState("live")); + expect(registry.get(ready)).toBe(true); + registry.dispose(); + }); + + it("allows a hosted client to load projects without a primary environment", () => { + const { catalog, shells, ready, registry } = makeHarness(false); + registry.set(catalog, catalogState([REMOTE])); + expect(registry.get(ready)).toBe(false); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(ready)).toBe(true); + registry.dispose(); + }); + + it("waits for live snapshots through offline startup and reconnect", () => { + const { catalog, shells, ready, registry } = makeHarness(); + registry.set(catalog, catalogState([LOCAL, REMOTE])); + registry.set(shells(LOCAL), shellState("live")); + expect(registry.get(ready)).toBe(false); + + // An old cache and a reconnect in progress can both omit a real project. + registry.set(shells(REMOTE), shellState("cached")); + expect(registry.get(ready)).toBe(false); + registry.set(shells(REMOTE), shellState("synchronizing")); + expect(registry.get(ready)).toBe(false); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(ready)).toBe(true); + + registry.set(shells(REMOTE), shellState("cached")); + expect(registry.get(ready)).toBe(false); + registry.set(shells(REMOTE), shellState("live")); + expect(registry.get(ready)).toBe(true); + registry.dispose(); + }); + + it("stops waiting for an environment only when it leaves the catalog", () => { + const { catalog, shells, ready, registry } = makeHarness(); + registry.set(catalog, catalogState([LOCAL, REMOTE])); + registry.set(shells(LOCAL), shellState("live")); + expect(registry.get(ready)).toBe(false); + registry.set(catalog, catalogState([LOCAL])); + expect(registry.get(ready)).toBe(true); + registry.dispose(); + }); +}); diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index dfb104e5c996..1f88da2f971d 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -7,12 +7,16 @@ import { createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, + type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentCatalogState } from "@t3tools/client-runtime/state/connections"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { isHostedStaticApp } from "../hostedPairing"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); @@ -46,3 +50,37 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { } return true; }).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped")); + +/** Cached or missing snapshots cannot establish that a saved project no longer exists. */ +export function createAllEnvironmentProjectSnapshotsReadyAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + readonly requiresPrimaryEnvironment: boolean; +}) { + return Atom.make((get) => { + const catalog = get(input.catalogValueAtom); + // The persisted catalog can emit before platform discovery registers the + // primary environment. Neither that gap nor an empty catalog proves absence. + if (!catalog.isReady || catalog.entries.size === 0) return false; + if ( + input.requiresPrimaryEnvironment && + !Array.from(catalog.entries.values()).some( + (entry) => entry.target._tag === "PrimaryConnectionTarget", + ) + ) { + return false; + } + for (const environmentId of catalog.entries.keys()) { + const shell = get(input.shellStateValueAtom(environmentId)); + if (shell.status !== "live" || Option.isNone(shell.snapshot)) return false; + } + return true; + }).pipe(Atom.withLabel("web-all-environment-project-snapshots-ready")); +} + +export const allEnvironmentProjectSnapshotsReadyAtom = + createAllEnvironmentProjectSnapshotsReadyAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, + requiresPrimaryEnvironment: !isHostedStaticApp(), + }); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index bd6db865992b..1a95acbcfbf7 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -13,6 +13,7 @@ import { resolveProjectExpanded, setDefaultAdvertisedEndpointKey, setProjectExpanded, + setSidebarProjectScopeKey, setThreadChangedFilesExpanded, type UiState, } from "./uiStateStore"; @@ -21,6 +22,7 @@ function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], + sidebarProjectScopeKey: null, threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -144,6 +146,15 @@ describe("uiStateStore pure functions", () => { defaultAdvertisedEndpointKey: null, }); }); + + it("stores the sidebar project scope and resets it to all projects", () => { + const scoped = setSidebarProjectScopeKey(makeUiState(), "github.com/pingdotgg/t3code"); + + expect(scoped.sidebarProjectScopeKey).toBe("github.com/pingdotgg/t3code"); + expect(setSidebarProjectScopeKey(scoped, "github.com/pingdotgg/t3code")).toBe(scoped); + expect(setSidebarProjectScopeKey(scoped, null).sidebarProjectScopeKey).toBeNull(); + expect(setSidebarProjectScopeKey(scoped, "").sidebarProjectScopeKey).toBeNull(); + }); }); describe("parsePersistedState", () => { @@ -177,6 +188,7 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + sidebarProjectScopeKey: null, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -297,6 +309,7 @@ describe("uiStateStore persistence", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + sidebarProjectScopeKey: null, threadChangedFilesExpansionVersion: 2, threadChangedFilesExpandedById: { "environment:thread-1": { @@ -310,6 +323,18 @@ describe("uiStateStore persistence", () => { }); }); + it("restores the sidebar project scope across reloads", () => { + persistState(makeUiState({ sidebarProjectScopeKey: "github.com/pingdotgg/t3code" })); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(parsePersistedState(persisted).sidebarProjectScopeKey).toBe( + "github.com/pingdotgg/t3code", + ); + }); + it("drops the temporary expanded-only migration fallback when rewriting state", () => { const migrated = parsePersistedState({ expandedProjectCwds: ["/repo/a"], diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 048055639ce7..b14ce917c861 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -26,6 +26,7 @@ export interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; + sidebarProjectScopeKey?: string | null; threadChangedFilesExpansionVersion?: number; threadChangedFilesExpandedById?: Record>; } @@ -33,6 +34,10 @@ export interface PersistedUiState { export interface UiProjectState { projectExpandedById: Record; projectOrder: string[]; + // Logical project key the sidebar list is scoped to, or null for "all + // projects". Lives here so routes that unmount the sidebar (Settings) + // cannot reset the filter. + sidebarProjectScopeKey: string | null; } export interface UiThreadState { @@ -49,6 +54,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState const initialState: UiState = { projectExpandedById: {}, projectOrder: [], + sidebarProjectScopeKey: null, threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -84,6 +90,10 @@ function sanitizeBooleanRecord(value: unknown): Record { ); } +function sanitizeOptionalKey(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + function sanitizeTimestampRecord(value: unknown): Record { if (!value || typeof value !== "object") { return {}; @@ -131,11 +141,8 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) : {}, - defaultAdvertisedEndpointKey: - typeof parsed.defaultAdvertisedEndpointKey === "string" && - parsed.defaultAdvertisedEndpointKey.length > 0 - ? parsed.defaultAdvertisedEndpointKey - : null, + defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey), + sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey), }; } @@ -206,6 +213,7 @@ export function persistState(state: UiState): void { projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, + sidebarProjectScopeKey: state.sidebarProjectScopeKey, threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, } satisfies PersistedUiState), @@ -305,6 +313,17 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } +export function setSidebarProjectScopeKey(state: UiState, projectKey: string | null): UiState { + const nextKey = sanitizeOptionalKey(projectKey); + if (state.sidebarProjectScopeKey === nextKey) { + return state; + } + return { + ...state, + sidebarProjectScopeKey: nextKey, + }; +} + export function resolveProjectExpanded( projectExpandedById: Readonly>, preferenceKeys: readonly string[], @@ -387,6 +406,7 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; + setSidebarProjectScopeKey: (projectKey: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], @@ -405,6 +425,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), + setSidebarProjectScopeKey: (projectKey) => + set((state) => setSidebarProjectScopeKey(state, projectKey)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => From a5bbad910f78cc14eef8baa94fe6f46676f78d5a Mon Sep 17 00:00:00 2001 From: Darahaas Yajamanyam <63366288+darahaas15@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:51:15 +0530 Subject: [PATCH 09/69] fix(server): surface Claude safety model fallback notices instead of dropping them (#8853) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/provider/Layers/ClaudeAdapter.test.ts | 35 ++++++++++++++----- .../src/provider/Layers/ClaudeAdapter.ts | 9 ++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 6b02c95f598f..afea9a605084 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3449,10 +3449,14 @@ describe("ClaudeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEvents: Array = []; - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => runtimeEvents.push(event)), - ).pipe(Effect.forkChild); + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil( + (event) => + event.type === "session.state.changed" && event.payload.reason === "api_retry:3/10", + ), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.startSession({ threadId: THREAD_ID, @@ -3498,7 +3502,6 @@ describe("ClaudeAdapterLive", () => { uuid: "tu", }, { type: "system", subtype: "commands_changed", session_id: "session", uuid: "cc" }, - { type: "system", subtype: "model_refusal_fallback", session_id: "session", uuid: "mrf" }, { type: "system", subtype: "local_command_output", session_id: "session", uuid: "lco" }, { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, @@ -3545,6 +3548,21 @@ describe("ClaudeAdapterLive", () => { ]) { harness.query.emit(message as unknown as SDKMessage); } + // Safety model-fallback notices DO surface as a warning row. + harness.query.emit({ + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + original_model: "claude-fable-5", + fallback_model: "claude-opus-4-8", + request_id: "req_test", + api_refusal_category: "cyber", + api_refusal_explanation: null, + content: "Safeguards flagged this message. Switched to Opus 4.8.", + session_id: "session", + uuid: "mrf", + } as unknown as SDKMessage); // High-priority notifications DO surface as a warning row. harness.query.emit({ type: "system", @@ -3602,15 +3620,15 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "retry", } as unknown as SDKMessage); - yield* Effect.yieldNow; - yield* Effect.yieldNow; + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); - // Exactly three warnings: the high-priority notification, the + // Exactly four warnings: the fallback notice, high-priority notification, // warning-level informational note, and the refusal. Nothing else. assert.deepEqual( warnings.map((event) => event.payload.message), [ + "Safeguards flagged this message. Switched to Opus 4.8.", "context window nearly full", "Stop hook prevented continuation", "The request was declined by the API.", @@ -3638,7 +3656,6 @@ describe("ClaudeAdapterLive", () => { event.payload.reason.startsWith("api_retry:"), ); assert.equal(heartbeat?.type, "session.state.changed"); - runtimeEventsFiber.interruptUnsafe(); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6096fd7cbef7..d295821d8dfa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3655,6 +3655,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* emitRuntimeWarning(context, message.text, message); } return; + case "model_refusal_fallback": + // A safety fallback switched the model mid-session (e.g. Fable 5 + // retried on Opus 4.8 after a flagged request). The CLI ships the + // user-facing notice in `content`; surface it like high-priority + // notifications so the rest of the session isn't silently served + // by a different model. + yield* emitRuntimeWarning(context, message.content, message); + return; // Inner protocol/UX details with no T3 surface today — consumed // deliberately so they don't masquerade as unknown-subtype warnings. // `background_tasks_changed` is a roster snapshot ({tasks: [...]}); the @@ -3663,7 +3671,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // source. `control_request_progress` is a liveness heartbeat for an // in-flight control request. `worker_shutting_down` is a Remote // Control worker notice; the session close path reports the outcome. - case "model_refusal_fallback": case "local_command_output": case "plugin_install": case "commands_changed": From caf4981e32f4fd3c9adb8339b5bec2d95d09b7aa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:22:17 -0700 Subject: [PATCH 10/69] fix(web): reload saved colors when reopening the theme editor (#9847) --- .../settings/ThemeEditorHost.test.tsx | 191 ++++++++++++++++++ .../components/settings/ThemeEditorHost.tsx | 27 ++- 2 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/settings/ThemeEditorHost.test.tsx diff --git a/apps/web/src/components/settings/ThemeEditorHost.test.tsx b/apps/web/src/components/settings/ThemeEditorHost.test.tsx new file mode 100644 index 000000000000..eaf0c1b06b2c --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorHost.test.tsx @@ -0,0 +1,191 @@ +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import { + getThemeDefinition, + installCustomTheme, + invalidateCustomThemes, + parseThemeFile, + removeCustomTheme, + THEME_FILE_VERSION, + themeColorToHex, + updateCustomTheme, + type ThemeDefinition, +} from "../../themePalette"; +import type { ThemeEditorSession } from "./themeEditorStore"; + +const state = vi.hoisted(() => ({ + session: null as ThemeEditorSession | null, + closeThemeEditor: vi.fn(), + onStoreChange: vi.fn(), + subscriptions: new Set<() => void>(), + theme: { + theme: "system", + themeHalves: null, + setTheme: vi.fn(), + refreshTheme: vi.fn(), + }, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + const subscription = reactHookHarness.useRef<(() => void) | null>(null); + if (!subscription.current) { + subscription.current = subscribe(() => state.onStoreChange()); + state.subscriptions.add(subscription.current); + } + return getSnapshot(); + }, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../hooks/useTheme", () => ({ useTheme: () => state.theme })); +vi.mock("./themeEditorStore", () => ({ + useThemeEditorStore: (select: (store: typeof state) => unknown) => select(state), +})); +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn() }, + stackedThreadToast: (value: unknown) => value, +})); + +import { ThemeEditorHost } from "./ThemeEditorHost"; + +function renderEditor() { + hooks.beginRender(); + const host = ThemeEditorHost() as ReactElement<{ + children: ReactElement<{ + editingTheme: ThemeDefinition | null; + seedTheme: ThemeDefinition | null; + }>; + }> | null; + return host?.props.children.props ?? null; +} + +describe("ThemeEditorHost", () => { + beforeEach(() => { + hooks.reset(); + state.session = null; + state.onStoreChange.mockReset(); + const storage = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + invalidateCustomThemes(); + }); + + afterEach(() => { + for (const unsubscribe of state.subscriptions) unsubscribe(); + state.subscriptions.clear(); + vi.unstubAllGlobals(); + invalidateCustomThemes(); + }); + + it.each(["editingTheme", "seedTheme"] as const)( + "reopens the same %s with its saved colors", + (field) => { + const theme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: "saved-colors", + name: "Saved colors", + appearance: "dark", + colors: { accent: "#1f6e4a" }, + }), + ); + const session = { + id: 1, + editingThemeId: field === "editingTheme" ? theme.id : null, + seedThemeId: field === "seedTheme" ? theme.id : null, + seedName: null, + initialAppearance: "dark" as const, + }; + state.session = session; + expect(themeColorToHex(renderEditor()?.[field]?.colors.accent ?? "")).toBe("#1f6e4a"); + + updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } }); + expect(themeColorToHex(getThemeDefinition(theme.id)?.colors.accent ?? "")).toBe("#7241b8"); + state.session = null; + expect(renderEditor()).toBeNull(); + state.session = { ...session, id: 2 }; + + expect(themeColorToHex(renderEditor()?.[field]?.colors.accent ?? "")).toBe("#7241b8"); + }, + ); + + it.each(["editingTheme", "seedTheme"] as const)( + "refreshes an open %s when the library changes", + (field) => { + const theme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: "updated-theme", + name: "Updated theme", + appearance: "dark", + colors: { accent: "#1f6e4a" }, + }), + ); + state.session = { + id: 1, + editingThemeId: field === "editingTheme" ? theme.id : null, + seedThemeId: field === "seedTheme" ? theme.id : null, + seedName: null, + initialAppearance: "dark", + }; + let editor = renderEditor(); + state.onStoreChange.mockImplementation(() => { + editor = renderEditor(); + }); + + updateCustomTheme({ ...theme, colors: { ...theme.colors, accent: "#7241b8" } }); + + expect(themeColorToHex(editor?.[field]?.colors.accent ?? "")).toBe("#7241b8"); + }, + ); + + it("does not keep editing a theme removed from the library", () => { + const theme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: "removed-theme", + name: "Removed theme", + appearance: "dark", + colors: { accent: "#1f6e4a" }, + }), + ); + state.session = { + id: 1, + editingThemeId: theme.id, + seedThemeId: null, + seedName: null, + initialAppearance: "dark", + }; + let editor = renderEditor(); + expect(editor?.editingTheme?.id).toBe(theme.id); + state.onStoreChange.mockImplementation(() => { + editor = renderEditor(); + }); + + removeCustomTheme(theme.id); + + expect(editor?.editingTheme).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ThemeEditorHost.tsx b/apps/web/src/components/settings/ThemeEditorHost.tsx index 7eca6a2de523..1ffc48fef627 100644 --- a/apps/web/src/components/settings/ThemeEditorHost.tsx +++ b/apps/web/src/components/settings/ThemeEditorHost.tsx @@ -1,7 +1,12 @@ -import { lazy, Suspense, useCallback } from "react"; +import { lazy, Suspense, useCallback, useSyncExternalStore } from "react"; import { useTheme } from "../../hooks/useTheme"; -import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; +import { + getThemeDefinition, + subscribeToCustomThemes, + type ThemeAppearance, + type ThemeDefinition, +} from "../../themePalette"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { useThemeEditorStore } from "./themeEditorStore"; @@ -12,6 +17,14 @@ const ThemeEditorPanel = lazy(() => import("./ThemeEditorPanel").then((module) => ({ default: module.ThemeEditorPanel })), ); +function useThemeDefinition(id: string | null | undefined) { + return useSyncExternalStore( + subscribeToCustomThemes, + () => (id ? (getThemeDefinition(id) ?? null) : null), + () => null, + ); +} + /** * Renders the theme editor above the router. The editor paints its draft on * the live app, so it has to outlive the settings route: the point is to walk @@ -21,6 +34,9 @@ export function ThemeEditorHost() { const session = useThemeEditorStore((store) => store.session); const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor); const { theme, setTheme, themeHalves, refreshTheme } = useTheme(); + // A saved definition can change without its id changing between sessions. + const editingTheme = useThemeDefinition(session?.editingThemeId); + const seedTheme = useThemeDefinition(session?.seedThemeId); // The panel reports which path it actually took: a theme removed while its // editor is open resolves to null there, so the save becomes a create even @@ -95,13 +111,6 @@ export function ThemeEditorHost() { if (!session) return null; - // Resolve on every render: an edit or import can change the stored - // definitions while a session is open. - const editingTheme = session.editingThemeId - ? (getThemeDefinition(session.editingThemeId) ?? null) - : null; - const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null; - return ( Date: Fri, 4 Sep 2026 17:23:06 -0700 Subject: [PATCH 11/69] feat(desktop): import cookies from Safari (#7262) Co-authored-by: Claude Opus 5 (1M context) --- .../src/electron/ElectronShell.test.ts | 14 + apps/desktop/src/electron/ElectronShell.ts | 29 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 11 + apps/desktop/src/preload.ts | 2 + .../preview/BrowserImport/BrowserImport.ts | 50 +- .../BrowserImport/SafariCookies.test.ts | 443 ++++++++++++++++++ .../preview/BrowserImport/SafariCookies.ts | 263 +++++++++++ .../src/preview/BrowserImport/Sources.test.ts | 103 ++++ .../src/preview/BrowserImport/Sources.ts | 96 +++- apps/desktop/src/window/DesktopWindow.test.ts | 2 + .../settings/BrowserImportWizard.tsx | 88 +++- .../IntegrationsSettings.logic.test.ts | 1 + .../settings/IntegrationsSettings.tsx | 15 + .../browserImportWizard.logic.test.ts | 32 ++ .../settings/browserImportWizard.logic.ts | 21 +- apps/web/src/localApi.test.ts | 8 + apps/web/src/localApi.ts | 11 + docs/user/browser-import.md | 6 + packages/contracts/src/browserImport.ts | 4 + packages/contracts/src/ipc.ts | 14 + 22 files changed, 1189 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts create mode 100644 apps/desktop/src/preview/BrowserImport/SafariCookies.ts diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b000..17f3e06039b6 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -36,6 +36,20 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens the Full Disk Access settings anchor", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openSystemSettings("full-disk-access"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [ + ["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("opens remote SSH editor URLs", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfebd0f..0ac4f8f9cc6a 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,4 +1,8 @@ -import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + remoteSchemeForEditor, + type SystemSettingsPane, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +10,20 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; +/** + * Deep links to individual System Settings panes. These are app-fixed, not + * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep + * arbitrary link schemes from reaching the OS handler — and open through their + * own path below. The pane rather than the URL crosses the IPC boundary, so a + * renderer can only ask for one of these known destinations. + * + * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor. + */ +const SYSTEM_SETTINGS_URLS: Record = { + "full-disk-access": + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", +}; + // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) // must reach the OS handler; every other non-web scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); @@ -43,6 +61,8 @@ export class ElectronShell extends Context.Service< ElectronShell, { readonly openExternal: (rawUrl: unknown) => Effect.Effect; + /** Opens a known System Settings pane by identifier, not by URL. */ + readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -59,6 +79,13 @@ export const make = ElectronShell.of({ ), ), }), + openSystemSettings: (pane) => + Effect.promise(() => + Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( + () => true, + () => false, + ), + ), copyText: (text) => Effect.sync(() => { Electron.clipboard.writeText(text); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2cdffbefb7ad..3e30083064af 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -38,6 +38,7 @@ import { getSystemLocale, getWindowFullscreenState, openExternal, + openSystemSettings, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(openSystemSettings); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..5b2c815eaa42 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..61de1361a311 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -9,6 +9,7 @@ import { PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, REMOTE_CAPABLE_EDITOR_IDS, + SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; @@ -298,6 +299,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const openSystemSettings = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, + payload: SystemSettingsPaneSchema, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) { + const shell = yield* ElectronShell.ElectronShell; + return yield* shell.openSystemSettings(pane); + }), +}); + export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..74001dd785d3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + openSystemSettings: (pane: string) => + ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index e92f2f05e05c..386b3ef6f813 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -27,6 +27,7 @@ import * as BrowserSession from "../BrowserSession.ts"; import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; import type { CookieReadResult } from "./CookieDatabase.ts"; import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; +import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts"; import { BROWSER_IMPORT_SOURCES, resolveCookieDatabase, @@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; if (yield* isSourceRunning(definition, context)) return "browserRunning"; + // Safari's jar is found by `stat`, which TCC permits without Full Disk + // Access — so a Safari that lists as ready may still refuse the read. Probe + // the grant here, so the wizard can open on the permission step and a + // post-grant recheck can tell granted from still-denied, rather than only + // discovering it by attempting the import. + if (definition.engine === "safari") { + const jar = yield* resolveCookieDatabase(definition, context, "."); + if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess"; + } return undefined; }); @@ -254,25 +264,29 @@ export const make = Effect.gen(function* BrowserImportMake() { const userDataDirectory = definition.userDataDirectory(pathContext); const read: Effect.Effect< CookieReadResult, - ChromiumCookieReadError | FirefoxCookieReadError, + ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError, FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner > = - definition.engine === "firefox" - ? readFirefoxCookies(databasePath).pipe( + definition.engine === "safari" + ? readSafariCookies(databasePath).pipe( Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), ) - : readChromiumCookies({ - cookieDatabasePath: databasePath, - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - linuxSecretApplication: definition.linuxSecretApplication, - ...(platform === "win32" && userDataDirectory !== undefined - ? { - windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), - } - : {}), - platform, - }); + : definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + keychainService: definition.keychainService, + keychainAccount: definition.keychainAccount, + linuxSecretApplication: definition.linuxSecretApplication, + ...(platform === "win32" && userDataDirectory !== undefined + ? { + windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"), + } + : {}), + platform, + }); const result = yield* read.pipe( Effect.scoped, @@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() { Effect.fail( new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), ), + // Safari's reasons are already user-facing: a TCC refusal is the Full + // Disk Access prompt, anything else is a read failure. + SafariCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), }), ); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts new file mode 100644 index 000000000000..f5d07f765943 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts @@ -0,0 +1,443 @@ +// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar +// format byte by byte. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; + +import { + isPermissionDenied, + parseBinaryCookies, + readSafariCookies, + safariAccessDenied, + SafariCookieReadError, +} from "./SafariCookies.ts"; + +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +interface FixtureCookie { + readonly domain: string; + readonly name: string; + readonly path: string; + readonly value: string; + readonly flags: number; + /** Seconds since 2001-01-01, as Safari stores them. */ + readonly expiry: number; +} + +/** Encodes one cookie exactly as Safari lays it out. */ +function encodeCookie(cookie: FixtureCookie): Buffer { + const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; + const headerSize = 56; + const offsets: number[] = []; + let cursor = headerSize; + for (const value of strings) { + offsets.push(cursor); + cursor += Buffer.byteLength(value) + 1; + } + const size = cursor; + + const buffer = Buffer.alloc(size); + buffer.writeUInt32LE(size, 0); + buffer.writeUInt32LE(0, 4); + buffer.writeUInt32LE(cookie.flags, 8); + buffer.writeUInt32LE(0, 12); + buffer.writeUInt32LE(offsets[0]!, 16); + buffer.writeUInt32LE(offsets[1]!, 20); + buffer.writeUInt32LE(offsets[2]!, 24); + buffer.writeUInt32LE(offsets[3]!, 28); + buffer.writeUInt32LE(0, 32); + buffer.writeUInt32LE(0, 36); + buffer.writeDoubleLE(cookie.expiry, 40); + buffer.writeDoubleLE(0, 48); + strings.forEach((value, index) => { + buffer.write(value, offsets[index]!, "utf8"); + }); + return buffer; +} + +/** Builds a single-page `Cookies.binarycookies` file. */ +function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { + const encoded = cookies.map(encodeCookie); + const headerSize = 12 + encoded.length * 4; + const offsets: number[] = []; + let cursor = headerSize; + for (const cookie of encoded) { + offsets.push(cursor); + cursor += cookie.length; + } + + const page = Buffer.alloc(cursor); + page.writeUInt32BE(0x0000_0100, 0); + page.writeUInt32LE(encoded.length, 4); + offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4)); + encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!)); + + const header = Buffer.alloc(8 + 4); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(1, 4); + header.writeUInt32BE(page.length, 8); + return Buffer.concat([header, page]); +} + +describe("parseBinaryCookies", () => { + it("reads Safari's format and rebases its 2001 epoch", () => { + const file = encodeBinaryCookies([ + { + domain: ".apple.com", + name: "session", + path: "/", + value: "abc", + // secure | httpOnly + flags: 0x1 | 0x4, + expiry: 800_000_000, + }, + { + domain: "example.test", + name: "plain", + path: "/app", + value: "v", + flags: 0, + expiry: 0, + }, + ]); + + expect(parseBinaryCookies(file)).toEqual([ + { + url: "https://apple.com/", + name: "session", + value: "abc", + domain: ".apple.com", + path: "/", + secure: true, + httpOnly: true, + // Safari counts from 2001-01-01, Electron from 1970. + expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, + // The format predates SameSite; Lax is the safe modern default. + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only: no leading dot in the jar, so no `domain` for Electron, + // which would otherwise re-add the dot and widen it to subdomains. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + expirationDate: undefined, + sameSite: "lax", + }, + ]); + }); + + it("keeps __Host- cookies host-only so Electron accepts them", () => { + const file = encodeBinaryCookies([ + { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "https://example.test/", + name: "__Host-id", + domain: undefined, + }); + }); + + it("brackets IPv6 hosts in the cookie URL", () => { + const file = encodeBinaryCookies([ + { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + + expect(parseBinaryCookies(file)[0]).toMatchObject({ + url: "http://[::1]/", + domain: undefined, + }); + }); + + it("reads cookies spread across multiple pages", () => { + // Safari pages its cookie file, and a single-page reader would silently + // return only the first slice. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + // Splice the two single-page files into one two-page file. + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(firstPage.length, 8); + header.writeUInt32BE(secondPage.length, 12); + + const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage])); + + expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); + }); + + it("rejects a page that runs past the end of the file", () => { + // `Buffer.subarray` clamps rather than throwing, so an overlong first page + // swallows the second one's bytes and advances the cursor past the end. + // Every cookie after the boundary then vanishes from a "successful" import. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, + ]); + const second = encodeBinaryCookies([ + { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, + ]); + const firstPage = first.subarray(12); + const secondPage = second.subarray(12); + const header = Buffer.alloc(16); + header.write("cook", 0, "latin1"); + header.writeUInt32BE(2, 4); + // Declares more bytes for page one than the file holds in total. + header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); + header.writeUInt32BE(secondPage.length, 12); + + expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow( + SafariCookieReadError, + ); + }); + + it("rejects a record whose declared size runs past its page", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + // The record's own length is what bounds its string offsets; an inflated + // one lets them read the following record's bytes as this cookie's value. + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(0xffff, recordStart); + + expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); + }); + + it("rejects records truncated inside the 56-byte header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (let size = 48; size < 56; size += 1) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(size, recordStart); + expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("rejects record offsets that point into the page header or an earlier record", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const firstRecord = valid.readUInt32LE(pageStart + 8); + + // Pointing the second offset at the page's offset table would let those + // table bytes parse as a fabricated record. + const intoTable = Buffer.from(valid); + intoTable.writeUInt32LE(4, pageStart + 12); + expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); + + // Pointing it back at the first record makes the same bytes count twice. + const overlapping = Buffer.from(valid); + overlapping.writeUInt32LE(firstRecord, pageStart + 12); + expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); + + // And a well-formed two-record page still parses. + expect(parseBinaryCookies(valid)).toHaveLength(2); + }); + + it("rejects string offsets that point into the record header", () => { + const valid = encodeBinaryCookies([ + { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, + ]); + const pageStart = 8 + 4; + const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); + + for (const offsetField of [16, 20, 24, 28]) { + const corrupt = Buffer.from(valid); + corrupt.writeUInt32LE(55, recordStart + offsetField); + expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( + SafariCookieReadError, + ); + } + }); + + it("accepts the checksum and property-list trailer Safari writes", () => { + const file = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const checksum = Buffer.alloc(8); + const plist = Buffer.from("bplist00 stub"); + const plistLength = Buffer.alloc(4); + plistLength.writeUInt32BE(plist.length, 0); + + expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); + expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); + }); + + it("rejects a jar whose page table stops short of its contents", () => { + // A second, undeclared page after the first would be silently dropped — + // the cookies it holds vanish from the import with no error — so a file + // the header does not fully describe is refused instead. + const first = encodeBinaryCookies([ + { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, + ]); + const extraPage = encodeBinaryCookies([ + { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, + ]).subarray(12); + + expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( + SafariCookieReadError, + ); + // A trailer that claims a property list it doesn't contain is refused too. + const badLength = Buffer.alloc(4); + badLength.writeUInt32BE(99, 0); + expect(() => + parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), + ).toThrow(SafariCookieReadError); + }); + + it("rejects a file that is not binarycookies", () => { + expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( + SafariCookieReadError, + ); + }); +}); + +describe("readSafariCookies", () => { + it.effect("adds the cookie path and parser cause to malformed jar failures", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFileString(jar, "not a cookie jar"); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + assert.equal(error.cookieDatabasePath, jar); + assert.instanceOf(error.cause, SafariCookieReadError); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a TCC denial as a permission the user can grant", () => + Effect.gen(function* () { + // What Full Disk Access actually looks like: the file is there, the read + // is refused with EPERM. Effect tags that `Unknown`, not + // `PermissionDenied`, so the reader has to look at the errno. Reporting + // it as a generic failure would send the user looking for a missing + // browser instead of a checkbox. + const denied = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + + const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe( + Effect.flip, + Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })), + ); + + assert.equal(error.reason, "needsFullDiskAccess"); + }), + ); + + it.effect("reports an ordinary permission failure as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix + // it, so it must not be routed to that grant. + yield* fileSystem.chmod(jar, 0o000); + + const error = yield* readSafariCookies(jar).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + it.effect("reports a missing jar as a plain read failure", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + + const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); + + assert.equal(error.reason, "readFailed"); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("safariAccessDenied", () => { + const eperm = PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "open", + pathOrDescriptor: "/protected/Cookies.binarycookies", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }); + const denied = (error: PlatformError.PlatformError) => + FileSystem.layerNoop({ open: () => Effect.fail(error) }); + + it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => + Effect.gen(function* () { + // `stat` finds the jar without the grant, so only an open tells the + // listing whether the import would actually be allowed. + assert.isTrue( + yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( + Effect.provide(denied(eperm)), + ), + ); + }), + ); + + it.effect("does not read a readable jar, or any other failure, as denied", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); + const jar = `${directory}/Cookies.binarycookies`; + yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); + assert.isFalse(yield* safariAccessDenied(jar)); + // Missing entirely is "not installed", not "denied". + assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); + +describe("isPermissionDenied", () => { + // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified + // against Safari's TCC-protected jar, whose denial is EPERM, tagged + // `Unknown` rather than `PermissionDenied`. + const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => + ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; + + it("treats a TCC EPERM denial as permission denied", () => { + // The regression: EPERM is tagged `Unknown`, so checking the tag alone + // reported Safari's Full Disk Access refusal as a generic read failure. + expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); + }); + + it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { + // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk + // Access, so it stays a plain read failure; only TCC's EPERM routes there. + expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); + }); + + it("does not treat an unrelated failure as permission denied", () => { + expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/SafariCookies.ts b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts new file mode 100644 index 000000000000..88aa856b83e9 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/SafariCookies.ts @@ -0,0 +1,263 @@ +/** + * Safari cookie extraction. + * + * Safari does not encrypt its cookies; it stores them in a proprietary + * `Cookies.binarycookies` file inside its app container. The protection is + * TCC, not cryptography — the file lives under a path only apps with Full Disk + * Access may read, so the gate is a permission the user grants in System + * Settings rather than a key to obtain. + * + * The format, big-endian throughout except the page bodies: + * + * magic "cook", u32 pageCount, u32 pageSize[pageCount], then each page: + * u32 0x00000100, u32le cookieCount, u32le cookieOffset[cookieCount], + * then each cookie: + * u32le size, u32le unknown, u32le flags, u32le unknown, + * u32le urlOffset, nameOffset, pathOffset, valueOffset, + * u64 end-of-header, f64 expiry, f64 creation, then NUL-terminated + * strings at the offsets above (relative to the cookie start). + * + * @module SafariCookies + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { cookieScope, type ImportedCookie } from "./CookieDatabase.ts"; + +/** Safari's timestamps count seconds from 2001-01-01, not the UNIX epoch. */ +const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; + +/** `u32 0x00000100`, `u32le cookieCount`, then one `u32le` offset per cookie. */ +const COOKIE_PAGE_HEADER_SIZE = 12; +/** Through the `f64 creation` field; string bytes follow. */ +const COOKIE_RECORD_HEADER_SIZE = 56; + +const FLAG_SECURE = 0x1; +const FLAG_HTTP_ONLY = 0x4; + +export const SafariCookieReadFailure = Schema.Literals(["needsFullDiskAccess", "readFailed"]); +export type SafariCookieReadFailure = typeof SafariCookieReadFailure.Type; + +export class SafariCookieReadError extends Schema.TaggedErrorClass()( + "SafariCookieReadError", + { + reason: SafariCookieReadFailure, + /** + * Which jar the read was for. The parser raises this before a path is in + * hand, so it is optional rather than required. + */ + cookieDatabasePath: Schema.optional(Schema.String), + /** Kept for the log; never surfaced to the user. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.cookieDatabasePath === undefined + ? `Could not read Safari cookies: ${this.reason}.` + : `Could not read Safari cookies at ${this.cookieDatabasePath}: ${this.reason}.`; + } +} + +const isSafariCookieReadError = Schema.is(SafariCookieReadError); + +/** Reads a NUL-terminated ASCII string at an offset. */ +function readCString(buffer: Buffer, start: number): string { + const end = buffer.indexOf(0, start); + return buffer.toString("utf8", start, end === -1 ? buffer.length : end); +} + +export function parseBinaryCookies(buffer: Buffer): ReadonlyArray { + if (buffer.length < 8 || buffer.toString("latin1", 0, 4) !== "cook") { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + const pageCount = buffer.readUInt32BE(4); + // Every declared structure is bounds-checked against what the file actually + // contains, and a mismatch fails the read. `Buffer.subarray` clamps silently, + // so accepting a short page or an overlong record would return a cookie set + // that is quietly missing entries or carrying fields read out of the next + // record — a partial import the user has no way to notice. + if (8 + pageCount * 4 > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const pageSizes: number[] = []; + for (let index = 0; index < pageCount; index += 1) { + pageSizes.push(buffer.readUInt32BE(8 + index * 4)); + } + + const cookies: ImportedCookie[] = []; + let pageStart = 8 + pageCount * 4; + + for (const pageSize of pageSizes) { + if (pageSize < COOKIE_PAGE_HEADER_SIZE || pageStart + pageSize > buffer.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const page = buffer.subarray(pageStart, pageStart + pageSize); + pageStart += pageSize; + + // Page bodies switch to little-endian after the big-endian header. + const cookieCount = page.readUInt32LE(4); + const offsetTableEnd = COOKIE_PAGE_HEADER_SIZE + cookieCount * 4; + if (offsetTableEnd > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Every record accepted so far, so a later offset cannot point back into + // one of them: the page header, the offset table, and earlier records are + // all bytes that would otherwise parse as a fabricated cookie. + const accepted: Array = []; + for (let index = 0; index < cookieCount; index += 1) { + const cookieStart = page.readUInt32LE(8 + index * 4); + if (cookieStart < offsetTableEnd || cookieStart + COOKIE_RECORD_HEADER_SIZE > page.length) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + // Bounded by the record's own length so a string offset cannot run past + // it into the following record's bytes. + const recordSize = page.readUInt32LE(cookieStart); + const cookieEnd = cookieStart + recordSize; + if ( + recordSize < COOKIE_RECORD_HEADER_SIZE || + cookieEnd > page.length || + accepted.some(([start, end]) => cookieStart < end && cookieEnd > start) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + accepted.push([cookieStart, cookieEnd]); + const cookie = page.subarray(cookieStart, cookieEnd); + + const flags = cookie.readUInt32LE(8); + const urlOffset = cookie.readUInt32LE(16); + const nameOffset = cookie.readUInt32LE(20); + const pathOffset = cookie.readUInt32LE(24); + const valueOffset = cookie.readUInt32LE(28); + const expiry = cookie.readDoubleLE(40); + + // Offsets are relative to the record; one pointing outside it would + // otherwise read a neighbouring cookie's bytes as this one's value. + if ( + [urlOffset, nameOffset, pathOffset, valueOffset].some( + (offset) => offset < COOKIE_RECORD_HEADER_SIZE || offset >= cookie.length, + ) + ) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + const domain = readCString(cookie, urlOffset); + const name = readCString(cookie, nameOffset); + const path = readCString(cookie, pathOffset); + const value = readCString(cookie, valueOffset); + if (domain === "" || name === "") continue; + + const secure = (flags & FLAG_SECURE) !== 0; + const expirationDate = + expiry > 0 ? Math.floor(expiry) + APPLE_EPOCH_OFFSET_SECONDS : undefined; + + cookies.push({ + // Safari marks domain cookies with a leading dot like the other + // engines, so the shared scope rule applies: host-only cookies keep + // `domain` undefined, or Electron widens them to every subdomain. + ...cookieScope(domain, path || "/", secure), + name, + value, + path: path || "/", + secure, + httpOnly: (flags & FLAG_HTTP_ONLY) !== 0, + expirationDate, + // Bits 3–5 of the flags carry something SameSite-shaped, but no public + // description of them agrees and real jars do not match any of them + // cleanly. Lax is the modern browser default; claiming "none" would + // widen every imported cookie's scope. + sameSite: "lax", + }); + } + } + + // Safari writes an 8-byte checksum after the pages, then an optional + // length-prefixed property list. Anything else past the declared pages — + // in particular whole extra pages — means the page table does not describe + // the file, and a jar the header lies about is refused rather than + // imported with cookies silently missing. + const trailer = buffer.length - pageStart; + // Legal shapes: nothing, the 8-byte checksum alone, or checksum + u32 + // length + exactly that many property-list bytes. + const validTrailer = + trailer === 0 || + trailer === 8 || + (trailer >= 12 && trailer === 8 + 4 + buffer.readUInt32BE(pageStart + 8)); + if (!validTrailer) { + throw new SafariCookieReadError({ reason: "readFailed" }); + } + + return cookies; +} + +/** + * Whether a filesystem error is the OS refusing access. + * + * A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than + * `PermissionDenied` (reserved for EACCES), so the underlying errno is checked + * too — otherwise a Full Disk Access refusal is reported as a generic read + * failure and the user is never told what to grant. + */ +export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => { + // TCC denies with EPERM, which Effect tags `Unknown` rather than + // `PermissionDenied` — so the errno is what identifies it. EACCES (and the + // `PermissionDenied` tag it maps to) is an ordinary POSIX permission or + // ACL failure that granting Full Disk Access cannot fix, so it stays a plain + // read failure rather than sending the user to a grant that won't help. + const code = (error.reason as { cause?: { code?: unknown } }).cause?.code; + return code === "EPERM"; +}; + +/** + * Whether reading the jar is refused by TCC. `stat` succeeds on the jar + * inside Safari's container even without Full Disk Access — that is what lets + * the listing find it — so presence alone cannot tell granted from denied. + * Opening it for read is what TCC gates: EPERM means the grant is missing. + * Anything else (including a missing jar) is not a permission answer. + */ +export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe( + Effect.as(false), + Effect.catch((cause) => Effect.succeed(isPermissionDenied(cause))), + Effect.scoped, + ); +}); + +export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( + cookiePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const contents = yield* fileSystem.readFile(cookiePath).pipe( + Effect.mapError((cause) => { + // TCC denies the read even though the file exists — a permission the user + // grants in System Settings rather than a missing browser. macOS never + // prompts for Full Disk Access, so there is no dialog to wait on; the + // read just fails, and it fails with EPERM, which Effect surfaces as an + // `Unknown` system error rather than `PermissionDenied` (that is EACCES). + return new SafariCookieReadError({ + reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed", + cookieDatabasePath: cookiePath, + cause, + }); + }), + ); + // The parser throws on a malformed jar; catch it here so callers see a typed + // failure rather than a defect. + return yield* Effect.try({ + try: () => parseBinaryCookies(Buffer.from(contents)), + catch: (cause) => + isSafariCookieReadError(cause) + ? new SafariCookieReadError({ + reason: cause.reason, + cookieDatabasePath: cookiePath, + cause, + }) + : new SafariCookieReadError({ + reason: "readFailed", + cookieDatabasePath: cookiePath, + cause, + }), + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index a83290448a4a..a867f78497b4 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -1093,3 +1093,106 @@ describe("listSourceProfiles hardening", () => { ), ); }); + +describe("Safari profiles", () => { + const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!; + const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD"; + const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD"; + + const fixture = Effect.fnUntraced(function* () { + const context = yield* withSourceHome(); + const fileSystem = yield* FileSystem.FileSystem; + const root = safari.userDataDirectory(context)!; + const library = context.path.dirname(root); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default"); + const store = (uuid: string) => + context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies"); + for (const uuid of [workUuid, otherUuid]) { + yield* fileSystem.makeDirectory(store(uuid), { recursive: true }); + yield* fileSystem.writeFileString( + context.path.join(store(uuid), "Cookies.binarycookies"), + uuid, + ); + } + yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true }); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + return { context, root, store, metadata }; + }); + + it.effect("discovers named profiles and resolves only the selected profile's cookies", () => + run( + Effect.gen(function* () { + const { context, root, store, metadata } = yield* fixture(); + yield* Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(metadata); + try { + database.exec(`CREATE TABLE bookmarks ( + title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0, + type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2, + deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0 + )`); + const insert = database.prepare( + "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)", + ); + insert.run("", "DefaultProfile", 0); + insert.run("Ping", workUuid, 0); + insert.run("Deleted", otherUuid, 1); + insert.run("Unsafe", "../../outside", 0); + database.exec( + "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)", + ); + } finally { + database.close(); + } + }); + const profiles = yield* listSourceProfiles(safari, context); + assert.deepEqual(profiles, [ + { directory: ".", name: "Personal" }, + { directory: store(workUuid), name: "Ping" }, + ]); + assert.strictEqual( + yield* resolveCookieDatabase(safari, context, "."), + context.path.join(root, "Cookies.binarycookies"), + ); + const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory); + assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies")); + const fileSystem = yield* FileSystem.FileSystem; + assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid); + yield* fileSystem.remove(selected!); + assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory)); + assert.deepEqual(yield* listSourceProfiles(safari, context), profiles); + }), + ), + ); + + for (const metadataState of ["missing", "corrupt"] as const) { + it.effect(`recovers separate cookie stores when metadata is ${metadataState}`, () => + run( + Effect.gen(function* () { + const { context, store, metadata } = yield* fixture(); + const fileSystem = yield* FileSystem.FileSystem; + if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid"); + yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies")); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + { directory: store(workUuid), name: workUuid.toLowerCase() }, + ]); + assert.isTrue(yield* isSourceInstalled(safari, context)); + }), + ), + ); + } + + it.effect("keeps Safari without profiles available", () => + run( + Effect.gen(function* () { + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(safari, context), [ + { directory: ".", name: "Safari" }, + ]); + assert.isFalse(yield* isSourceInstalled(safari, context)); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 702933a432b3..6075f0ad56a3 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,10 +1,11 @@ /** * Importable browser sources. * - * Two engines are modelled. Chromium-family browsers keep cookies in an + * Chromium-family browsers keep cookies in an * encrypted SQLite database whose key lives in an OS credential store; Firefox * keeps them in plain SQLite with no key at all, so it needs no keychain and - * works the same on every platform. + * works the same on every platform. Safari uses binary cookie files, with + * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account @@ -31,7 +32,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -export type BrowserImportEngine = "chromium" | "firefox"; +export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than @@ -177,6 +178,26 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray + context.platform === "darwin" + ? context.path.join( + context.home, + "Library", + "Containers", + "com.apple.Safari", + "Data", + "Library", + "Cookies", + ) + : undefined, + }, { id: "firefox", name: "Firefox", @@ -220,6 +241,9 @@ export const cookieDatabaseCandidatePaths = ( if (definition.engine === "firefox") { return [context.path.join(profilePath, "cookies.sqlite")]; } + if (definition.engine === "safari") { + return [context.path.join(profilePath, "Cookies.binarycookies")]; + } // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade // leaves the legacy file behind, so prefer the current one and fall back. return [ @@ -386,6 +410,64 @@ const withCookieCounts = ( ), ); +const SafariProfileRows = Schema.Array( + Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }), +); +const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows); +const isSafariProfileUuid = (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + +const listSafariProfiles = Effect.fnUntraced(function* ( + context: BrowserImportPathContext, + root: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const library = context.path.dirname(root); + const metadata = context.path.join(library, "Safari", "SafariTabs.db"); + const declared = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* decodeSafariProfiles( + yield* sql` + select title, external_uuid from bookmarks + where parent = 0 and type = 1 and subtype = 2 and deleted = 0 + order by order_index + `, + ); + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), + Effect.orElseSucceed(() => []), + ); + const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); + const profiles: Array = [ + { + directory: ".", + name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", + }, + ]; + const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); + const profileDirectory = (uuid: string) => + context.path.join(stores, uuid.toLowerCase(), "Cookies"); + for (const profile of declared) { + if (!isSafariProfileUuid(profile.external_uuid)) continue; + profiles.push({ + directory: profileDirectory(profile.external_uuid), + name: profile.title?.trim() || profile.external_uuid, + }); + } + // If Safari's metadata is unavailable, recover stores that have cookies. + // With readable metadata, avoid resurrecting deleted profiles left on disk. + if (declared.length === 0) { + const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries.filter(isSafariProfileUuid).sort()) { + const directory = context.path.join(stores, entry, "Cookies"); + if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { + profiles.push({ directory, name: entry }); + } + } + } + return profiles; +}); + /** * Profiles the source browser knows about. * @@ -403,6 +485,10 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( const root = definition.userDataDirectory(context); if (root === undefined) return []; + if (definition.engine === "safari") { + return yield* listSafariProfiles(context, root); + } + if (definition.engine === "firefox") { const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), @@ -773,11 +859,15 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") const root = definition.userDataDirectory(context); if (root === undefined) return false; // Probe the source's own lock state rather than scanning the process table. + // Safari keeps no lock and writes its jar atomically, so a running instance + // is not a hazard there. + // // Chromium exposes its lock through the cookie jar on Windows and through a // user-data SingletonLock on POSIX. Firefox keeps its locks inside each // profile under three names across platforms (`lock` on macOS and Linux, // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's // at the root finds nothing and reports a running browser as importable. + if (definition.engine === "safari") return false; if (definition.engine !== "firefox") { if (context.platform === "win32") { return yield* windowsChromiumCookiesAreHeld(definition, context); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index abf6f220eca4..bdd03865c7bf 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -280,6 +280,7 @@ function makeTestLayer(input: { input.openedExternalUrls?.push(url); return true; }), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, @@ -380,6 +381,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), + openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index a18cb9173ff6..a02bb5dab24e 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -23,6 +23,7 @@ import { canCloseWizard, isRetryableReason, formatSkippedDomains, + fullDiskAccessRecheckStep, outcomeToStep, refreshedSourceProfileDirectory, refreshedSourceStep, @@ -55,6 +56,8 @@ interface BrowserImportWizardProps { }) => Promise; /** Re-checks the source's availability after the user quits the browser. */ readonly onRefreshSource: () => Promise; + /** Opens the OS setting that grants access to a protected cookie store. */ + readonly onOpenFullDiskAccessSettings: () => void; readonly onClose: () => void; } @@ -72,6 +75,7 @@ export function BrowserImportWizard({ canCreateProfile, onImport, onRefreshSource, + onOpenFullDiskAccessSettings, onClose, }: BrowserImportWizardProps) { const [source, setSource] = useState(initialSource); @@ -111,8 +115,13 @@ export function BrowserImportWizard({ }); }; - const recheckAfterQuit = () => { - setStep({ step: "checking" }); + // Re-lists the source after the user did something outside the app (quit the + // browser, granted access) and routes to wherever the refreshed source says. + const recheckSource = ( + check: "browser" | "fullDiskAccess", + nextStep: (refreshed: BrowserImportSource | undefined) => WizardStep, + ) => { + setStep({ step: "checking", check }); void onRefreshSource() .then((refreshed) => { if (refreshed) { @@ -121,20 +130,30 @@ export function BrowserImportWizard({ refreshedSourceProfileDirectory(current, refreshed), ); } - setStep(refreshedSourceStep(refreshed)); + setStep(nextStep(refreshed)); }) .catch(() => setStep({ step: "blocked", reason: "readFailed" })); }; + const recheckAfterQuit = () => recheckSource("browser", refreshedSourceStep); + const recheckFullDiskAccess = () => recheckSource("fullDiskAccess", fullDiskAccessRecheckStep); return ( (open || !canCloseWizard(step) ? undefined : onClose())}> {step.step === "quit" ? ( + ) : step.step === "fullDiskAccess" ? ( + ) : step.step === "importing" ? ( ) : step.step === "checking" ? ( - + ) : step.step === "done" ? ( void; }; +function FullDiskAccessStep({ + source, + onCancel, + onOpenSettings, + onGranted, + stillRequired, +}: { + readonly source: BrowserImportSource; + readonly onCancel: () => void; + readonly onOpenSettings: () => void; + readonly onGranted: () => void; + readonly stillRequired: boolean; +}) { + return ( + <> + + Let T3 Code read {source.name}’s cookies + + To import cookies from {source.name}, T3 Code needs Full Disk Access. Turn it on in System + Settings, then come back to finish the import — you can revoke it again once the import is + done. + + + {stillRequired ? ( + +

+ Full Disk Access is still required. If you just turned it on, quit and reopen T3 Code, + then try again. +

+
+ ) : null} + + + + + + + ); +} function ConfigureStep({ source, destinationEnvironmentName, @@ -382,16 +444,28 @@ function ImportingStep() { ); } -function CheckingStep({ sourceName }: { readonly sourceName: string }) { +function CheckingStep({ + sourceName, + check, +}: { + readonly sourceName: string; + readonly check: "browser" | "fullDiskAccess"; +}) { return ( <> Checking {sourceName} - Checking whether the browser has closed. + + {check === "fullDiskAccess" + ? "Checking Full Disk Access." + : "Checking whether the browser has closed."} + - Checking… + + {check === "fullDiskAccess" ? "Checking access…" : "Checking…"} + ); diff --git a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts index 1c839f0cfdf8..c6d091b59e39 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts +++ b/apps/web/src/components/settings/IntegrationsSettings.logic.test.ts @@ -85,6 +85,7 @@ const failure = (reason: string) => ({ describe("importFailureReason", () => { it("recovers the reason token from the flattened message", () => { + expect(importFailureReason(failure("needsFullDiskAccess"))).toBe("needsFullDiskAccess"); expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning"); expect(importFailureReason(failure("readFailed"))).toBe("readFailed"); expect(importFailureReason(failure("keychainUnavailable"))).toBe("keychainUnavailable"); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 5b1d9ba5e21e..a3e96107b29c 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -55,6 +55,8 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { readLocalApi } from "~/localApi"; + import { toastManager } from "../ui/toast"; import { AlertDialog, @@ -1168,6 +1170,19 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { runWizardImport(importSession.source, importSession.environmentId, input) } onRefreshSource={() => refreshImportSource(importSession.source.id)} + onOpenFullDiskAccessSettings={() => { + // Rejects outside the desktop shell (and on shells that predate the + // method), so the one toast covers every way the link can fail. + void readLocalApi() + ?.shell.openSystemSettings("full-disk-access") + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not open System Settings", + description: "Open Privacy & Security → Full Disk Access manually.", + }); + }); + }} onClose={() => setImportSession(null)} /> ) : null} diff --git a/apps/web/src/components/settings/browserImportWizard.logic.test.ts b/apps/web/src/components/settings/browserImportWizard.logic.test.ts index 64bd94f635c4..3df4a95d6c33 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.test.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.test.ts @@ -7,6 +7,7 @@ import { initialTargetSelection, isRetryableReason, formatSkippedDomains, + fullDiskAccessRecheckStep, outcomeToStep, refreshedSourceProfileDirectory, refreshedSourceStep, @@ -51,6 +52,15 @@ describe("initialWizardStep", () => { expect(initialWizardStep(source())).toEqual({ step: "configure" }); }); + it("asks for Full Disk Access before choosing an import target", () => { + expect(initialWizardStep(source({ profiles: [], unavailable: "needsFullDiskAccess" }))).toEqual( + { + step: "fullDiskAccess", + resume: "configure", + }, + ); + }); + it("blocks on a reason nothing local can fix", () => { expect(initialWizardStep(source({ unavailable: "unsupportedPlatform" }))).toEqual({ step: "blocked", @@ -103,6 +113,14 @@ describe("outcomeToStep", () => { expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" }); }); + it("routes a Full Disk Access refusal to its own screen", () => { + expect(outcomeToStep({ kind: "blocked", reason: "needsFullDiskAccess" })).toEqual({ + step: "fullDiskAccess", + resume: "import", + checked: true, + }); + }); + it("surfaces every other failure on the blocked screen", () => { expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({ step: "blocked", @@ -134,6 +152,20 @@ describe("refreshedSourceStep", () => { }); }); +describe("fullDiskAccessRecheckStep", () => { + it("marks a still-denied access check for visible feedback", () => { + expect(fullDiskAccessRecheckStep(source({ unavailable: "needsFullDiskAccess" }))).toEqual({ + step: "fullDiskAccess", + resume: "configure", + checked: true, + }); + }); + + it("moves on once access reveals the source profiles", () => { + expect(fullDiskAccessRecheckStep(source())).toEqual({ step: "configure" }); + }); +}); + describe("refreshedSourceProfileDirectory", () => { const refreshed = source({ profiles: [ diff --git a/apps/web/src/components/settings/browserImportWizard.logic.ts b/apps/web/src/components/settings/browserImportWizard.logic.ts index 5479e307b13a..3b71345b9d5a 100644 --- a/apps/web/src/components/settings/browserImportWizard.logic.ts +++ b/apps/web/src/components/settings/browserImportWizard.logic.ts @@ -58,8 +58,13 @@ export type ImportOutcome = */ export type WizardStep = | { readonly step: "quit" } + | { + readonly step: "fullDiskAccess"; + readonly resume: "configure" | "import"; + readonly checked?: boolean; + } | { readonly step: "configure" } - | { readonly step: "checking" } + | { readonly step: "checking"; readonly check: "browser" | "fullDiskAccess" } | { readonly step: "importing" } | { readonly step: "done"; @@ -82,6 +87,9 @@ export function canCloseWizard(step: WizardStep): boolean { */ export function initialWizardStep(source: BrowserImportSource): WizardStep { if (source.unavailable === "browserRunning") return { step: "quit" }; + if (source.unavailable === "needsFullDiskAccess") { + return { step: "fullDiskAccess", resume: "configure" }; + } if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable }; if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" }; return { step: "configure" }; @@ -102,6 +110,11 @@ export function outcomeToStep(outcome: ImportOutcome): WizardStep { // other failure surfaces on the blocked screen, which offers a retry when // one could help. if (outcome.reason === "browserRunning") return { step: "quit" }; + if (outcome.reason === "needsFullDiskAccess") { + // The failed import already checked access, so explain that it is still + // denied instead of returning to an indistinguishable permission screen. + return { step: "fullDiskAccess", resume: "import", checked: true }; + } return { step: "blocked", reason: outcome.reason }; } @@ -111,6 +124,12 @@ export function refreshedSourceStep(source: BrowserImportSource | undefined): Wi return initialWizardStep(source); } +/** A denied FDA recheck returns to the permission step with visible feedback. */ +export function fullDiskAccessRecheckStep(source: BrowserImportSource | undefined): WizardStep { + const next = refreshedSourceStep(source); + return next.step === "fullDiskAccess" ? { ...next, checked: true } : next; +} + /** Preserve the chosen source profile when a post-quit refresh still lists it. */ export function refreshedSourceProfileDirectory( currentDirectory: string, diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..33c53a86c4e2 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -113,6 +113,14 @@ describe("LocalApi", () => { await expect(createLocalApi().dialogs.confirm("Delete this thread?")).resolves.toBe(false); }); + it("rejects opening System Settings when the desktop bridge is unavailable", async () => { + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().shell.openSystemSettings("full-disk-access")).rejects.toThrow( + "Unable to open System Settings.", + ); + }); + it("delegates host capabilities and persistence to the desktop bridge", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 8f55f65e40de..cafd04f9c048 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -29,6 +29,17 @@ function createBrowserLocalApi(): LocalApi { window.open(url, "_blank", "noopener,noreferrer"); }, + // Only the desktop shell can reach the OS; the web build (and older + // desktop shells that predate this method) have nothing to open. + openSystemSettings: async (pane) => { + if (!window.desktopBridge?.openSystemSettings) { + throw new Error("Unable to open System Settings."); + } + const opened = await window.desktopBridge.openSystemSettings(pane); + if (!opened) { + throw new Error("Unable to open System Settings."); + } + }, }, contextMenu: { show: async ( diff --git a/docs/user/browser-import.md b/docs/user/browser-import.md index 9aa4c6ca0e7f..93f2fad73741 100644 --- a/docs/user/browser-import.md +++ b/docs/user/browser-import.md @@ -10,6 +10,12 @@ unlock prompt if one appears. This is a one-time copy. Later login changes stay separate between the two browsers, and some sites may still require you to sign in again. +On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than +a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the +right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can +revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies +kept by additional Safari profiles are not. + On Windows, import supports Firefox and Helium profiles that use standard profile encryption. Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies are skipped on all platforms. diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index 4448ae37b472..2c7eb24821d3 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -25,6 +25,7 @@ export const BROWSER_IMPORT_SOURCE_IDS = [ "arc", "helium", "firefox", + "safari", ] as const; export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); @@ -42,6 +43,7 @@ export const BrowserImportUnavailableReason = Schema.Literals([ "notInstalled", "needsKeychainApproval", "keychainItemMissing", + "needsFullDiskAccess", "browserRunning", "unsupportedPlatform", ]); @@ -144,6 +146,8 @@ export const BROWSER_IMPORT_UNAVAILABLE_COPY: Readonly< needsKeychainApproval: "Needs Keychain access to read its cookies.", keychainItemMissing: "No encryption key in your Keychain — sign in to that browser once, then retry.", + needsFullDiskAccess: + "Give T3 Code Full Disk Access in System Settings → Privacy & Security, then retry.", browserRunning: "Quit the browser first so its cookie database can be read.", unsupportedPlatform: "Importing from this browser isn't possible on this platform.", }; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 798d5a777d5d..a14a4ed0a08a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1052,6 +1052,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ input: PreviewAutomationWaitForInput, }); +/** + * A System Settings pane the app can deep-link to. The identifier crosses IPC + * rather than a URL, so the renderer can only reach these known destinations. + */ +export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); +export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; /** The desktop client's OS platform, read from Electron's preload process. */ @@ -1119,6 +1126,11 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Open a System Settings pane by identifier. Optional: older desktop builds + * lack it, and callers no-op when it is missing. + */ + openSystemSettings?: (pane: SystemSettingsPane) => Promise; /** * Probe this desktop machine for installed remote-capable editor CLIs * (used for remote open-in-editor deep links). Optional: older desktop @@ -1267,6 +1279,8 @@ export interface LocalApi { }; shell: { openExternal: (url: string) => Promise; + /** Opens a known System Settings pane; no-ops outside the desktop app. */ + openSystemSettings: (pane: SystemSettingsPane) => Promise; }; contextMenu: { show: ( From 8faf031c2dac4f147c43fe32e326b76678c20c94 Mon Sep 17 00:00:00 2001 From: Michel Liao <107891771+Michel-Liao@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:27:31 -0400 Subject: [PATCH 12/69] fix(web): respect case in POSIX file links (#9309) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/web/src/filePathDisplay.test.ts | 6 ++ apps/web/src/filePathDisplay.ts | 10 +++- apps/web/src/markdown-links.test.ts | 55 +++++++++++++++++++ .../client-runtime/src/markdownLinks.test.ts | 12 +++- packages/client-runtime/src/markdownLinks.ts | 5 +- 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index ecceea09ca1e..4c49133a3138 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -38,4 +38,10 @@ describe("formatWorkspaceRelativePath", () => { ), ).toBe("t3code/apps/web/src/session-logic.ts:501:9"); }); + + it("keeps double-slash POSIX paths case-sensitive", () => { + expect(formatWorkspaceRelativePath("//tmp/project/probe.txt", "//tmp/Project")).toBe( + "//tmp/project/probe.txt", + ); + }); }); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index fc197a4092b5..36f5873cf8e0 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -4,6 +4,7 @@ import { splitFilePathPosition, stripSlashPrefixedWindowsDrive, } from "@t3tools/client-runtime/markdown-links"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; function normalizePathSeparators(path: string): string { return path.replaceAll("\\", "/"); @@ -30,10 +31,13 @@ export function formatWorkspaceRelativePath( normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), ); const workspaceLabel = fileBasename(normalizedWorkspaceRoot); - const pathForCompare = normalizedPath.toLowerCase(); - const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); + const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const workspaceForCompare = caseInsensitive + ? normalizedWorkspaceRoot.toLowerCase() + : normalizedWorkspaceRoot; const workspaceWithSeparator = `${workspaceForCompare}/`; - const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`; + const workspaceLabelWithSeparator = `${caseInsensitive ? workspaceLabel.toLowerCase() : workspaceLabel}/`; if (pathForCompare === workspaceForCompare) { displayPath = workspaceLabel; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index e3b1cc5b2249..5421c0d8781d 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -254,6 +254,61 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("does not classify a case-distinct POSIX sibling as a workspace file", () => { + expect( + resolveMarkdownFileLinkMeta( + "/tmp/t3code-case-test/project/probe.txt", + "/tmp/t3code-case-test/Project", + ), + ).toMatchObject({ + displayPath: "/tmp/t3code-case-test/project/probe.txt", + workspaceRelativePath: null, + }); + }); + + it("keeps Windows workspace comparisons case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta("C:/Users/MIKE/Project/src/main.ts", "c:/users/mike/project"), + ).toMatchObject({ + displayPath: "project/src/main.ts", + workspaceRelativePath: "src/main.ts", + }); + }); + + it("keeps drive-root workspace comparisons case-insensitive", () => { + expect(resolveMarkdownFileLinkMeta("C:/Users/MIKE/project.ts", "c:/")).toMatchObject({ + displayPath: "c:/Users/MIKE/project.ts", + workspaceRelativePath: "Users/MIKE/project.ts", + }); + }); + + it("keeps backslash UNC workspace comparisons case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "\\\\server\\share\\PROJECT\\src\\main.ts", + "\\\\Server\\Share\\Project", + ), + ).toMatchObject({ + displayPath: "Project/src/main.ts", + workspaceRelativePath: "src/main.ts", + }); + }); + + it.each([ + ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], + ["C:/Users/MIKE/file.ts", "c:/", "Users/MIKE/file.ts"], + ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], + ["/tmp/repo/file.ts%20", "/tmp/repo", "file.ts "], + ])("preserves the preview target for %s in workspace %s", (href, cwd, workspaceRelativePath) => { + expect(resolveMarkdownFileLinkMeta(href, cwd)).toMatchObject({ workspaceRelativePath }); + }); + + it("keeps an encoded final space in the absolute target", () => { + expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe( + "/tmp/repo/file.ts ", + ); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index cf8f9ae2d16e..42cd7a35e473 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -160,7 +160,17 @@ describe("workspaceRelativeFilePath", () => { ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], - ["/Repo/Project/src/main.ts", "/repo/project", "src/main.ts"], + ["/Repo/Project/src/main.ts", "/repo/project", null], + ["/tmp/case/project/probe.txt", "/tmp/case/Project", null], + ["//tmp/case/project/probe.txt", "//tmp/case/Project", null], + ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"], + ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"], + ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"], + ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"], + ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], + ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"], + ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], + ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "], ["/tmp/report.ts", "/repo/project", null], ["/repo/project-two/a.ts", "/repo/project", null], ["/repo/project/a.ts", undefined, null], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 81cce1fd7eee..2e455655d005 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -342,6 +342,9 @@ export function workspaceRelativeFilePath( const normalizedRoot = stripSlashPrefixedWindowsDrive( workspaceRoot.replaceAll("\\", "/"), ).replace(/\/+$/, ""); - if (!normalizedPath.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) return null; + const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1); } From fce8508456e7f9218eebb02ecd764f9b3a47df6b Mon Sep 17 00:00:00 2001 From: Joaquin Navarro Date: Fri, 4 Sep 2026 20:31:05 -0400 Subject: [PATCH 13/69] fix(server): surface a missing workspace folder instead of a spawn error (#5040) Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../providerService.integration.test.ts | 5 +- .../Layers/ProviderCommandReactor.test.ts | 60 +++++++- .../Layers/ProviderCommandReactor.ts | 5 + apps/server/src/provider/Errors.ts | 17 +++ .../provider/Layers/ProviderService.test.ts | 140 ++++++++++++++---- .../src/provider/Layers/ProviderService.ts | 24 ++- 6 files changed, 217 insertions(+), 34 deletions(-) diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 3ad85b1a68b9..0d041b36ecb8 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -100,7 +100,10 @@ const makeIntegrationFixture = (options?: { readonly analytics?: Layer.Layer { readonly stopSessionEffect?: () => Effect.Effect; readonly startSessionEffect?: ( session: ProviderSession, - ) => Effect.Effect; + ) => Effect.Effect; readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"]; }) { const now = "2026-01-01T00:00:00.000Z"; @@ -1217,6 +1221,58 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("shows the missing workspace message without a provider stack trace", () => + Effect.gen(function* () { + const attempted = yield* Deferred.make(); + const missingCwd = "/missing/project/worktree"; + const missingWorkspace = new ProviderWorkspaceMissingError({ + threadId: ThreadId.make("thread-1"), + cwd: missingCwd, + }); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: () => + Deferred.succeed(attempted, undefined).pipe( + Effect.andThen(Effect.fail(missingWorkspace)), + ), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-missing-workspace"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-missing-workspace"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Deferred.await(attempted); + yield* Effect.promise(() => harness.drain()); + + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session).toMatchObject({ + status: "error", + activeTurnId: null, + lastError: missingWorkspace.message, + }); + const failure = thread?.activities.find( + (activity) => activity.kind === "provider.turn.start.failed", + ); + expect(failure?.payload).toMatchObject({ detail: missingWorkspace.message }); + expect(harness.runtimeSessions).toEqual([]); + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + }), + ); + effectIt.effect("settles a failed provider startup and allows a clean retry", () => Effect.gen(function* () { let failStartup = true; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1589bc94b78b..b8e457e34ebb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -34,6 +34,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit import { ProviderAdapterRequestError, ProviderAdapterValidationError, + ProviderWorkspaceMissingError, } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; @@ -56,6 +57,7 @@ import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); +const isProviderWorkspaceMissingError = Schema.is(ProviderWorkspaceMissingError); const isProviderDriverKind = Schema.is(ProviderDriverKind); type ProviderIntentEvent = Extract< @@ -394,6 +396,9 @@ const make = Effect.gen(function* () { if (isProviderAdapterValidationError(failReason?.error)) { return failReason.error.issue; } + if (isProviderWorkspaceMissingError(failReason?.error)) { + return failReason.error.message; + } return Cause.pretty(cause); }; diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b4..4abb10554462 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -85,6 +85,22 @@ export class ProviderAdapterProcessError extends Schema.TaggedErrorClass()( + "ProviderWorkspaceMissingError", + { + threadId: Schema.String, + cwd: Schema.String, + }, +) { + override get message(): string { + return `This thread's workspace folder no longer exists or is not a directory: ${this.cwd}. Restore the folder at this path before retrying.`; + } +} + /** * ProviderValidationError - Invalid provider API input. */ @@ -197,6 +213,7 @@ export type ProviderAdapterError = export type ProviderServiceError = | ProviderValidationError | ProviderUnsupportedError + | ProviderWorkspaceMissingError | ProviderInstanceNotFoundError | ProviderSessionNotFoundError | ProviderSessionDirectoryPersistenceError diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 95e5a7983346..f5b9be91650f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -32,6 +32,7 @@ import { } from "@t3tools/shared/assistantCitations"; import { createModelSelection } from "@t3tools/shared/model"; import { it, assert, describe, vi } from "@effect/vitest"; +import { afterAll } from "vite-plus/test"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -54,6 +55,7 @@ import { ProviderAdapterSessionNotFoundError, ProviderUnsupportedError, ProviderValidationError, + ProviderWorkspaceMissingError, type ProviderAdapterError, } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; @@ -79,6 +81,16 @@ const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd( Layer.provide(NodeServices.layer), ); +// startSession verifies the workspace folder exists before dispatching to an +// adapter, so session cwd fixtures must be real directories. +const fixtureCwdRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "provider-service-test-")); +afterAll(() => NodeFS.rmSync(fixtureCwdRoot, { recursive: true, force: true })); +function fixtureCwd(name: string): string { + const dir = NodePath.join(fixtureCwdRoot, name); + NodeFS.mkdirSync(dir, { recursive: true }); + return dir; +} + const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asThreadId = (value: string): ThreadId => ThreadId.make(value); @@ -425,6 +437,7 @@ function makeProviderServiceLayer( const layer = it.layer( Layer.mergeAll( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -477,6 +490,7 @@ for (const [enabled, completed] of [ const scope = yield* Scope.make(); const services = yield* Layer.build( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), ), @@ -596,6 +610,7 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = Layer.mergeAll( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -638,6 +653,7 @@ it.effect("ProviderServiceLive flushes deferred completions during shutdown", () const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = Layer.mergeAll( makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -774,6 +790,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -857,6 +874,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(serverSettingsLayer), @@ -926,6 +944,7 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1044,7 +1063,7 @@ unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => { yield* provider.startSession(threadId, { providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "approval-required", }); if (!active) { @@ -1098,6 +1117,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1218,6 +1238,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(directoryLayer)); const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), Layer.provide(defaultServerSettingsLayer), @@ -1283,6 +1304,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -1310,7 +1332,7 @@ it.effect( const session = yield* provider.startSession(threadId, { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", threadId, }); @@ -1343,6 +1365,7 @@ it.effect( Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -1380,7 +1403,7 @@ it.effect( threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.cwd, fixtureCwd("project")); assert.deepEqual(startPayload.resumeCursor, updatedResumeCursor); assert.equal(startPayload.threadId, startedSession.threadId); } @@ -1394,6 +1417,61 @@ it.effect( ); routing.layer("ProviderServiceLive routing", (it) => { + it.effect.each([CODEX_DRIVER, CLAUDE_AGENT_DRIVER, CURSOR_DRIVER])( + "rejects missing, file, and saved workspace paths before starting %s", + (driver) => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const adapter = + driver === CODEX_DRIVER + ? routing.codex + : driver === CLAUDE_AGENT_DRIVER + ? routing.claude + : routing.cursor; + const cwd = fixtureCwd(`missing-workspace-${driver}`); + const movedCwd = `${cwd}-moved`; + const threadId = asThreadId(`missing-workspace-${driver}`); + const input = { + provider: driver, + providerInstanceId: ProviderInstanceId.make(driver), + threadId, + runtimeMode: "full-access" as const, + cwd, + }; + + yield* provider.startSession(threadId, input); + yield* provider.stopSession({ threadId }); + adapter.startSession.mockClear(); + NodeFS.renameSync(cwd, movedCwd); + + const failure = yield* provider.startSession(threadId, input).pipe(Effect.flip); + assert.instanceOf(failure, ProviderWorkspaceMissingError); + assert.include(failure.message, cwd); + assert.equal(adapter.startSession.mock.calls.length, 0); + + const { cwd: _cwd, ...savedInput } = input; + const savedFailure = yield* provider.startSession(threadId, savedInput).pipe(Effect.flip); + assert.instanceOf(savedFailure, ProviderWorkspaceMissingError); + assert.include(savedFailure.message, cwd); + assert.equal(adapter.startSession.mock.calls.length, 0); + + NodeFS.writeFileSync(cwd, "not a directory"); + const fileFailure = yield* provider.startSession(threadId, input).pipe(Effect.flip); + assert.instanceOf(fileFailure, ProviderWorkspaceMissingError); + assert.include(fileFailure.message, cwd); + assert.equal(adapter.startSession.mock.calls.length, 0); + + NodeFS.unlinkSync(cwd); + NodeFS.renameSync(movedCwd, cwd); + const restored = yield* provider.startSession(threadId, savedInput); + assert.equal(restored.cwd, cwd); + assert.equal(adapter.startSession.mock.calls.length, 1); + yield* provider.stopSession({ threadId }); + adapter.startSession.mockClear(); + adapter.stopSession.mockClear(); + }), + ); + it.effect("allows promptless continuation only for capable providers", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -1452,7 +1530,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); assert.equal(session.provider, "codex"); @@ -1522,7 +1600,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.cwd, fixtureCwd("project")); assert.deepEqual(startPayload.resumeCursor, session.resumeCursor); assert.equal(startPayload.threadId, session.threadId); } @@ -1799,7 +1877,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: CODEX_DRIVER, providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/feedback-project", + cwd: fixtureCwd("feedback-project"), runtimeMode: "full-access", }); yield* routing.codex.stopSession(threadId); @@ -1862,7 +1940,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-attach"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); @@ -1937,7 +2015,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); yield* routing.codex.stopSession(initial.threadId); @@ -1963,7 +2041,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project"); + assert.equal(startPayload.cwd, fixtureCwd("project")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -1982,7 +2060,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-reap-preserve"), - cwd: "/tmp/project-reap-preserve", + cwd: fixtureCwd("project-reap-preserve"), runtimeMode: "full-access", }); @@ -2017,7 +2095,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project-reap-preserve"); + assert.equal(startPayload.cwd, fixtureCwd("project-reap-preserve")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2033,7 +2111,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude"), - cwd: "/tmp/project-claude", + cwd: fixtureCwd("project-claude"), runtimeMode: "full-access", }); @@ -2049,7 +2127,7 @@ routing.layer("ProviderServiceLive routing", (it) => { }; assert.equal(startPayload.provider, "claudeAgent"); assert.equal(startPayload.providerInstanceId, claudeAgentInstanceId); - assert.equal(startPayload.cwd, "/tmp/project-claude"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude")); } }), ); @@ -2064,7 +2142,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/project-binding-mismatch", + cwd: fixtureCwd("project-binding-mismatch"), runtimeMode: "full-access", }); yield* directory.upsert({ @@ -2094,7 +2172,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId, - cwd: "/tmp/project-provider-replacement", + cwd: fixtureCwd("project-provider-replacement"), runtimeMode: "full-access", }); @@ -2105,7 +2183,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId, - cwd: "/tmp/project-provider-replacement", + cwd: fixtureCwd("project-provider-replacement"), runtimeMode: "full-access", }); @@ -2132,7 +2210,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-1"), - cwd: "/tmp/project-send-turn", + cwd: fixtureCwd("project-send-turn"), runtimeMode: "full-access", }); @@ -2157,7 +2235,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "codex"); - assert.equal(startPayload.cwd, "/tmp/project-send-turn"); + assert.equal(startPayload.cwd, fixtureCwd("project-send-turn")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2173,7 +2251,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-send-turn"), - cwd: "/tmp/project-claude-send-turn", + cwd: fixtureCwd("project-claude-send-turn"), modelSelection: createModelSelection( ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", @@ -2204,7 +2282,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude-send-turn")); assert.deepEqual( startPayload.modelSelection, createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ @@ -2368,6 +2446,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -2389,7 +2468,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-start"), - cwd: "/tmp/project-claude-start", + cwd: fixtureCwd("project-claude-start"), runtimeMode: "full-access", }); }).pipe(Effect.provide(firstProviderLayer)); @@ -2407,6 +2486,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -2430,7 +2510,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: initial.threadId, - cwd: "/tmp/project-claude-start", + cwd: fixtureCwd("project-claude-start"), runtimeMode: "full-access", }); }).pipe(Effect.provide(secondProviderLayer)); @@ -2446,7 +2526,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude-start"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude-start")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2476,6 +2556,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, firstRegistry), ), @@ -2497,7 +2578,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-claude-cwd"), - cwd: "/tmp/project-claude-cwd", + cwd: fixtureCwd("project-claude-cwd"), runtimeMode: "full-access", }); }).pipe(Effect.provide(firstProviderLayer)); @@ -2510,6 +2591,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(runtimeRepositoryLayer), ); const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), Layer.provide( Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, secondRegistry), ), @@ -2548,7 +2630,7 @@ routing.layer("ProviderServiceLive routing", (it) => { threadId?: string; }; assert.equal(startPayload.provider, "claudeAgent"); - assert.equal(startPayload.cwd, "/tmp/project-claude-cwd"); + assert.equal(startPayload.cwd, fixtureCwd("project-claude-cwd")); assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); assert.equal(startPayload.threadId, initial.threadId); } @@ -2742,7 +2824,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-metrics"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); @@ -2820,7 +2902,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: ProviderDriverKind.make("claudeAgent"), providerInstanceId: claudeAgentInstanceId, threadId: asThreadId("thread-send-metrics"), - cwd: "/tmp/project-send-metrics", + cwd: fixtureCwd("project-send-metrics"), runtimeMode: "full-access", }); @@ -4155,7 +4237,7 @@ validation.layer("ProviderServiceLive validation", (it) => { provider: ProviderDriverKind.make("codex"), providerInstanceId: codexInstanceId, threadId: asThreadId("thread-missing"), - cwd: "/tmp/project", + cwd: fixtureCwd("project"), runtimeMode: "full-access", }); @@ -4204,7 +4286,7 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { provider: CODEX_DRIVER, providerInstanceId: codexInstanceId, threadId: activeSessionThreadId, - cwd: "/tmp/project-active-session", + cwd: fixtureCwd("project-active-session"), runtimeMode: "full-access", }); listThreadIds.mockClear(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index cf9d9b395d5b..b853d779763c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -35,6 +35,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; @@ -55,8 +56,12 @@ import { providerTurnMetricAttributes, withMetrics, } from "../../observability/Metrics.ts"; -import { ProviderAdapterRequestError } from "../Errors.ts"; -import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts"; +import { + ProviderAdapterRequestError, + type ProviderAdapterError, + ProviderValidationError, + ProviderWorkspaceMissingError, +} from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; import * as ProviderService from "../Services/ProviderService.ts"; @@ -322,6 +327,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; + const fileSystem = yield* FileSystem.FileSystem; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); const timedOutNativeCompactions = new Set(); @@ -1235,6 +1241,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( : "none", "provider.cwd.effective": effectiveCwd ?? "", }); + if (effectiveCwd !== undefined) { + // Fail fast with an actionable error when the workspace folder is + // gone (e.g. moved, deleted, or replaced by a plain file). + // Otherwise every adapter surfaces this as a misleading "failed to + // spawn " process error. Stat failures other than "missing" + // fall through to the adapter. + const workspaceIsDirectory = yield* fileSystem.stat(effectiveCwd).pipe( + Effect.map((workspaceStat) => workspaceStat.type === "Directory"), + Effect.catch((statError) => Effect.succeed(statError.reason._tag !== "NotFound")), + ); + if (!workspaceIsDirectory) { + return yield* new ProviderWorkspaceMissingError({ threadId, cwd: effectiveCwd }); + } + } const adapter = yield* registry.getByInstance(resolvedInstanceId); yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId); yield* prepareMcpSession(threadId, resolvedInstanceId); From b6f72681da394369274121c4d1216f5d64a7a4bb Mon Sep 17 00:00:00 2001 From: Felipe Franco <67972456+fe-franco@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:36:28 -0300 Subject: [PATCH 14/69] fix: stop favicon requests for private link hosts on web and mobile (#5838) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: codex Co-authored-by: Julius Marminge Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/features/threads/ThreadFeed.tsx | 12 +- apps/web/src/browser/browserTargetResolver.ts | 156 +----------------- apps/web/src/components/ChatMarkdown.test.tsx | 31 ++++ apps/web/src/components/ChatMarkdown.tsx | 6 +- apps/web/src/lib/favicon.ts | 24 +-- packages/shared/package.json | 4 + packages/shared/src/favicon.test.ts | 46 +++++- packages/shared/src/favicon.ts | 16 ++ .../shared/src/hostClassification.test.ts | 112 +++++++++++++ packages/shared/src/hostClassification.ts | 149 +++++++++++++++++ 10 files changed, 377 insertions(+), 179 deletions(-) create mode 100644 packages/shared/src/hostClassification.test.ts create mode 100644 packages/shared/src/hostClassification.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ab48046fbd96..360b980edc95 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -86,6 +86,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -596,7 +597,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly href: string; readonly onPress: (href: string) => void; }) { - const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + const [failedHost, setFailedHost] = useState(null); + const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); return ( - {!failed ? ( + {faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( { failedMarkdownFaviconHosts.add(props.host); - setFailed(true); + setFailedHost(props.host); }} /> ) : ( diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index c06c60b5f740..4acb1e2b487f 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -4,158 +4,16 @@ import type { PreviewUrlResolution, } from "@t3tools/contracts"; import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; +import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; import { readPreparedConnection } from "~/state/session"; -export const normalizeHostname = (host: string): string => - host - .toLowerCase() - .replace(/^\[|\]$/g, "") - .replace(/\.+$/u, ""); - -const parseIpv4Address = (host: string): readonly number[] | null => { - const parts = normalizeHostname(host).split(".").map(Number); - return parts.length === 4 && - parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) - ? parts - : null; -}; - -const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => { - const normalized = normalizeHostname(host); - if (!normalized.startsWith("::ffff:")) return null; - const suffix = normalized.slice("::ffff:".length); - const dotted = parseIpv4Address(suffix); - if (dotted) return dotted; - const hextets = suffix.split(":"); - if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; - const high = Number.parseInt(hextets[0]!, 16); - const low = Number.parseInt(hextets[1]!, 16); - return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; -}; - -const parseIpv6Address = (host: string): readonly number[] | null => { - const normalized = normalizeHostname(host); - if (!normalized.includes(":")) return null; - const halves = normalized.split("::"); - if (halves.length > 2) return null; - const head = halves[0] ? halves[0].split(":") : []; - const tail = halves[1] ? halves[1].split(":") : []; - if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; - const missing = 8 - head.length - tail.length; - if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null; - return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) => - Number.parseInt(part, 16), - ); -}; - -const ipv6PrefixMatches = ( - address: readonly number[], - prefix: readonly number[], - prefixLength: number, -): boolean => { - const fullHextets = Math.floor(prefixLength / 16); - if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false; - const remainingBits = prefixLength % 16; - if (remainingBits === 0) return true; - const mask = (0xffff << (16 - remainingBits)) & 0xffff; - return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask); -}; - -const isPrivateIpv4Address = (parts: readonly number[]): boolean => - parts[0] === 0 || - parts[0] === 10 || - parts[0] === 127 || - (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - (parts[0] === 169 && parts[1] === 254) || - (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19); - -const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => - isPrivateIpv4Address(parts) || - parts[0]! >= 224 || - // Deliberately suppress the whole protocol-assignment block. IANA marks - // .9 and .10 globally reachable, but privacy-safe false negatives are - // preferable to disclosing another special-purpose address by mistake. - (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || - (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || - (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) || - (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || - (parts[0] === 203 && parts[1] === 0 && parts[2] === 113); - -export const isLocalLoopbackHost = (host: string): boolean => { - const normalized = normalizeHostname(host); - if (normalized === "localhost" || normalized === "::1") return true; - return parseIpv4Address(normalized)?.[0] === 127; -}; - -export const isPrivateNetworkHost = (host: string): boolean => { - const normalized = normalizeHostname(host); - if ( - normalized === "::" || - isLocalLoopbackHost(normalized) || - normalized.endsWith(".localhost") || - normalized.endsWith(".local") || - normalized === "home.arpa" || - normalized.endsWith(".home.arpa") || - (!normalized.includes(".") && !normalized.includes(":")) - ) { - return true; - } - if (normalized.endsWith(".ts.net")) return true; - const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); - if (parts) return isPrivateIpv4Address(parts); - const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; - if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; - const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); - return ( - Number.isInteger(firstIpv6Hextet) && - ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) - ); -}; - -/** Whether a hostname is eligible to be disclosed to a public favicon provider. */ -export const isPublicFaviconHost = (host: string): boolean => { - // A single trailing dot is a valid absolute DNS name. Repeated trailing - // dots are malformed and can conceal legacy numeric forms such as 127.1. - if (host.endsWith("..")) return false; - const normalized = normalizeHostname(host); - if (isPrivateNetworkHost(normalized)) return false; - if ( - [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( - (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), - ) - ) { - return false; - } - const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); - if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); - if (!normalized.includes(":")) return true; - const ipv6 = parseIpv6Address(normalized); - if (!ipv6) return false; - if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) { - const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff]; - return !isSpecialPurposeIpv4Address(embeddedIpv4); - } - const first = ipv6[0]!; - if ((first & 0xe000) !== 0x2000) return false; - if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) { - const publicProtocolAssignment = - (ipv6[1] === 1 && - ipv6.slice(2, 7).every((part) => part === 0) && - [1, 2, 3].includes(ipv6[7]!)) || - ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) || - ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) || - ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) || - ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28); - return publicProtocolAssignment; - } - if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; - if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; - if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false; - return true; -}; +export { + normalizeHostname, + isLocalLoopbackHost, + isPrivateNetworkHost, + isPublicFaviconHost, +} from "@t3tools/shared/hostClassification"; const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { const connection = readPreparedConnection(environmentId); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 18a6c5115eeb..28ac42888eef 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -72,6 +72,37 @@ function codeButton(renderer: ReactTestRenderer, label: string) { return button.props as ComponentProps; } +describe("ChatMarkdown favicon privacy", () => { + it("suppresses private link images while preserving public links across updates", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const markdown = (url: string) => ; + try { + await act(async () => { + renderer = create(markdown("https://github.com")); + }); + expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([ + "https://www.google.com/s2/favicons?domain=github.com&sz=32", + ]); + for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) { + await act(async () => { + renderer!.update(markdown(url)); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + } + await act(async () => { + renderer!.update(markdown("https://github.com")); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(1); + } finally { + await act(async () => { + renderer?.unmount(); + }); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown streaming", () => { it("preserves code controls and details without highlighting an unchanged fence again", async () => { const highlighter = await getSyntaxHighlighterPromise("text"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index deaa68ebdaee..48fafbd0d76d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -28,6 +28,7 @@ import type { ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1133,16 +1134,17 @@ const failedFaviconHosts = new Set(); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); + const faviconUrl = faviconUrlForOrigin(`https://${host}`); return ( - {failedHost === host || failedFaviconHosts.has(host) ? ( + {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( ` fallback when the returned URL - * fails to load via an `onError` handler. - */ -const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; - -export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { - if (!rawUrl) return null; - try { - const url = new URL(rawUrl); - if (!url.host) return null; - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - if (!isPublicFaviconHost(url.hostname)) return null; - return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; - } catch { - return null; - } -} +export { faviconUrlForOrigin } from "@t3tools/shared/favicon"; diff --git a/packages/shared/package.json b/packages/shared/package.json index efbc62320a52..fd932b8b146b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -266,6 +266,10 @@ "./nodeSqliteClient": { "types": "./src/nodeSqliteClient.ts", "import": "./src/nodeSqliteClient.ts" + }, + "./hostClassification": { + "types": "./src/hostClassification.ts", + "import": "./src/hostClassification.ts" } }, "scripts": { diff --git a/packages/shared/src/favicon.test.ts b/packages/shared/src/favicon.test.ts index c29727544efa..ce80a079b3fd 100644 --- a/packages/shared/src/favicon.test.ts +++ b/packages/shared/src/favicon.test.ts @@ -1,6 +1,50 @@ import { describe, expect, it } from "@effect/vitest"; -import { explicitFaviconUrl, faviconUrlForPage, toolActivityFaviconUrl } from "./favicon.ts"; +import { + explicitFaviconUrl, + faviconUrlForOrigin, + faviconUrlForPage, + toolActivityFaviconUrl, +} from "./favicon.ts"; + +describe("faviconUrlForOrigin", () => { + it.each([ + "http://192.168.1.10:8080", + "http://localhost:3000", + "http://home.arpa", + "https://printer.local.", + "https://api.internal", + "https://box.tailnet.ts.net", + "http://127.1", + "http://0x7f000001", + "http://[::]", + "http://[::1]", + "http://[::ffff:192.168.1.10]", + "http://[fd00::1]", + "http://[fe80::1]", + "http://100.64.0.1", + "http://198.51.100.1", + "http://[2001:db8::1]", + "http://service.test", + "http://private.onion", + "http://127.1..", + ])("does not disclose %s to the favicon provider", (origin) => { + expect(faviconUrlForOrigin(origin)).toBeNull(); + }); + + it("keeps the public origin, port and requested size", () => { + expect(faviconUrlForOrigin("https://github.com:8443/pingdotgg/t3code?private=query", 64)).toBe( + "https://www.google.com/s2/favicons?domain=github.com%3A8443&sz=64", + ); + }); + + it.each([null, undefined, "", "invalid URL", "file:///tmp/private", "data:text/plain,private"])( + "rejects an invalid or unsupported origin %s", + (origin) => { + expect(faviconUrlForOrigin(origin)).toBeNull(); + }, + ); +}); describe("faviconUrlForPage", () => { it("uses the page origin instead of a third-party favicon service", () => { diff --git a/packages/shared/src/favicon.ts b/packages/shared/src/favicon.ts index 88bf46a985bc..a3286b28e99a 100644 --- a/packages/shared/src/favicon.ts +++ b/packages/shared/src/favicon.ts @@ -1,3 +1,5 @@ +import { isPublicFaviconHost } from "./hostClassification.ts"; + /** * Mirrors Codex's generic Browser Use fallback: ask the page origin for its * conventional favicon and let the image element fall back to a browser glyph. @@ -79,3 +81,17 @@ export function toolActivityFaviconUrl( faviconUrlForPage(icon.pageUrl, size) ); } + +/** Return a public favicon URL without disclosing private or reserved hosts. */ +export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + if (!url.host) return null; + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (!isPublicFaviconHost(url.hostname)) return null; + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(url.host)}&sz=${size}`; + } catch { + return null; + } +} diff --git a/packages/shared/src/hostClassification.test.ts b/packages/shared/src/hostClassification.test.ts new file mode 100644 index 000000000000..9fcb1a7667cb --- /dev/null +++ b/packages/shared/src/hostClassification.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isPublicFaviconHost } from "./hostClassification.ts"; + +describe("isPublicFaviconHost", () => { + it("treats public hosts as public", () => { + for (const host of [ + "github.com", + "www.google.com", + "t3.chat", + "sub.domain.example.co.uk", + "8.8.8.8", + "1.1.1.1", + "100.200.1.1", + "172.32.0.1", + "192.167.1.1", + "11.0.0.1", + ]) { + expect(isPublicFaviconHost(host), host).toBe(true); + } + }); + + it("detects private IPv4 ranges", () => { + for (const host of [ + "0.0.0.0", + "10.0.0.1", + "10.255.255.255", + "127.0.0.1", + "192.168.1.10", + "172.16.0.1", + "172.31.255.255", + "169.254.1.1", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + }); + + it("detects the Tailscale 100.64.0.0/10 range", () => { + for (const host of ["100.64.0.1", "100.100.100.100", "100.126.17.15", "100.127.255.255"]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("100.63.255.255")).toBe(true); + expect(isPublicFaviconHost("100.128.0.1")).toBe(true); + }); + + it("detects private host names and suffixes", () => { + for (const host of [ + "localhost", + "air", + "printer.local", + "api.internal", + "router.home.arpa", + "home.arpa", + "box.tailnet.ts.net", + "AIR.TAILE8BEA7.TS.NET", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + }); + + it("detects private IPv6 addresses", () => { + for (const host of ["::1", "[::1]", "fd00::1", "fc00::1", "fe80::1", "FD12:3456::1"]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("2606:4700:4700::1111")).toBe(true); + }); + + it("detects IPv4-mapped IPv6 addresses in both spellings", () => { + for (const host of [ + "::ffff:192.168.1.10", + "::ffff:10.0.0.1", + "::ffff:100.126.17.15", + "[::ffff:192.168.1.10]", + // c0a8:010a is 192.168.1.10, 0a00:0001 is 10.0.0.1. + "::ffff:c0a8:010a", + "::ffff:a00:1", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("::ffff:8.8.8.8")).toBe(true); + expect(isPublicFaviconHost("::ffff:808:808")).toBe(true); + }); + + it("ignores a trailing DNS root label", () => { + for (const host of [ + "localhost.", + "printer.local.", + "api.internal.", + "box.tailnet.ts.net.", + "air.", + ]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + expect(isPublicFaviconHost("github.com.")).toBe(true); + }); + + it("detects names under .localhost", () => { + for (const host of ["app.localhost", "api.app.localhost", "APP.LOCALHOST"]) { + expect(isPublicFaviconHost(host), host).toBe(false); + } + }); + + it("treats an empty host as private", () => { + expect(isPublicFaviconHost("")).toBe(false); + expect(isPublicFaviconHost(" ")).toBe(false); + }); + + it("rejects malformed IPv4 text as a public host", () => { + expect(isPublicFaviconHost("10.0.0.999")).toBe(true); + expect(isPublicFaviconHost("10.0.0")).toBe(true); + }); +}); diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts new file mode 100644 index 000000000000..7191fa78221f --- /dev/null +++ b/packages/shared/src/hostClassification.ts @@ -0,0 +1,149 @@ +export const normalizeHostname = (host: string): string => + host + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.+$/u, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const parseIpv4MappedIpv6Address = (host: string): readonly number[] | null => { + const normalized = normalizeHostname(host); + if (!normalized.startsWith("::ffff:")) return null; + const suffix = normalized.slice("::ffff:".length); + const dotted = parseIpv4Address(suffix); + if (dotted) return dotted; + const hextets = suffix.split(":"); + if (hextets.length !== 2 || hextets.some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; + const high = Number.parseInt(hextets[0]!, 16); + const low = Number.parseInt(hextets[1]!, 16); + return [high >>> 8, high & 0xff, low >>> 8, low & 0xff]; +}; + +const parseIpv6Address = (host: string): readonly number[] | null => { + const normalized = normalizeHostname(host); + if (!normalized.includes(":")) return null; + const halves = normalized.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves[1] ? halves[1].split(":") : []; + if ([...head, ...tail].some((part) => !/^[\da-f]{1,4}$/u.test(part))) return null; + const missing = 8 - head.length - tail.length; + if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) return null; + return [...head, ...Array.from({ length: missing }, () => "0"), ...tail].map((part) => + Number.parseInt(part, 16), + ); +}; + +const ipv6PrefixMatches = ( + address: readonly number[], + prefix: readonly number[], + prefixLength: number, +): boolean => { + const fullHextets = Math.floor(prefixLength / 16); + if (address.slice(0, fullHextets).some((part, index) => part !== prefix[index])) return false; + const remainingBits = prefixLength % 16; + if (remainingBits === 0) return true; + const mask = (0xffff << (16 - remainingBits)) & 0xffff; + return (address[fullHextets]! & mask) === (prefix[fullHextets]! & mask); +}; + +const isPrivateIpv4Address = (parts: readonly number[]): boolean => + parts[0] === 0 || + parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 198 && parts[1]! >= 18 && parts[1]! <= 19); + +const isSpecialPurposeIpv4Address = (parts: readonly number[]): boolean => + isPrivateIpv4Address(parts) || + parts[0]! >= 224 || + // Deliberately suppress the whole protocol-assignment block. IANA marks + // .9 and .10 globally reachable, but privacy-safe false negatives are + // preferable to disclosing another special-purpose address by mistake. + (parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || + (parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || + (parts[0] === 192 && parts[1] === 88 && parts[2] === 99) || + (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || + (parts[0] === 203 && parts[1] === 0 && parts[2] === 113); + +export const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + +export const isPrivateNetworkHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if ( + normalized === "::" || + isLocalLoopbackHost(normalized) || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") || + normalized === "home.arpa" || + normalized.endsWith(".home.arpa") || + (!normalized.includes(".") && !normalized.includes(":")) + ) { + return true; + } + if (normalized.endsWith(".ts.net")) return true; + const parts = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); + if (parts) return isPrivateIpv4Address(parts); + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); + return ( + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) + ); +}; + +/** Whether a hostname is eligible to be disclosed to a public favicon provider. */ +export const isPublicFaviconHost = (host: string): boolean => { + // A single trailing dot is a valid absolute DNS name. Repeated trailing + // dots are malformed and can conceal legacy numeric forms such as 127.1. + if (host.endsWith("..")) return false; + const normalized = normalizeHostname(host); + if (isPrivateNetworkHost(normalized)) return false; + if ( + [".alt", ".example", ".internal", ".invalid", ".onion", ".test"].some( + (suffix) => normalized === suffix.slice(1) || normalized.endsWith(suffix), + ) + ) { + return false; + } + const ipv4 = parseIpv4Address(normalized) ?? parseIpv4MappedIpv6Address(normalized); + if (ipv4) return !isSpecialPurposeIpv4Address(ipv4); + if (!normalized.includes(":")) return true; + const ipv6 = parseIpv6Address(normalized); + if (!ipv6) return false; + if (ipv6PrefixMatches(ipv6, [0x0064, 0xff9b, 0, 0, 0, 0, 0, 0], 96)) { + const embeddedIpv4 = [ipv6[6]! >>> 8, ipv6[6]! & 0xff, ipv6[7]! >>> 8, ipv6[7]! & 0xff]; + return !isSpecialPurposeIpv4Address(embeddedIpv4); + } + const first = ipv6[0]!; + if ((first & 0xe000) !== 0x2000) return false; + if (ipv6PrefixMatches(ipv6, [0x2001, 0, 0, 0, 0, 0, 0, 0], 23)) { + const publicProtocolAssignment = + (ipv6[1] === 1 && + ipv6.slice(2, 7).every((part) => part === 0) && + [1, 2, 3].includes(ipv6[7]!)) || + ipv6PrefixMatches(ipv6, [0x2001, 3, 0, 0, 0, 0, 0, 0], 32) || + ipv6PrefixMatches(ipv6, [0x2001, 4, 0x0112, 0, 0, 0, 0, 0], 48) || + ipv6PrefixMatches(ipv6, [0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28) || + ipv6PrefixMatches(ipv6, [0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28); + return publicProtocolAssignment; + } + if (ipv6PrefixMatches(ipv6, [0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32)) return false; + if (ipv6PrefixMatches(ipv6, [0x2002, 0, 0, 0, 0, 0, 0, 0], 16)) return false; + if (first === 0x3fff && (ipv6[1]! & 0xf000) === 0) return false; + return true; +}; From 2d5464afbc19058be8c125d5ee70481d721d14f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 17:39:11 -0700 Subject: [PATCH 15/69] fix(server): preserve native provider executable paths during updates (#9850) --- .../src/provider/providerMaintenance.test.ts | 54 +++++++++++++++++-- .../src/provider/providerMaintenance.ts | 5 +- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 35d0c04bb08e..54ff1b599560 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import { expect, it } from "@effect/vitest"; import * as NodeFS from "node:fs"; +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -8,6 +9,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import { HttpClient } from "effect/unstable/http"; import { createProviderVersionAdvisory, @@ -362,9 +364,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { - command: "native-package-tool update", + command: `${nativePackageToolPath} update`, - executable: "native-package-tool", + executable: nativePackageToolPath, args: ["update"], @@ -399,9 +401,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { - command: "scoped-package-tool upgrade", + command: `${scopedPackageToolPath} upgrade`, - executable: "scoped-package-tool", + executable: scopedPackageToolPath, args: ["upgrade"], @@ -411,6 +413,50 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); + it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-update-" }); + const nativeBinDir = NodePath.join(tempDir, "with spaces", ".scoped-package-tool", "bin"); + yield* fs.makeDirectory(nativeBinDir, { recursive: true }); + const binaryPath = NodePath.join(nativeBinDir, "scoped-package-tool"); + yield* fs.writeFileString(binaryPath, "#!/bin/sh\nprintf '%s' \"$1\"\n"); + yield* fs.chmod(binaryPath, 0o755); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + scopedPackageToolUpdate, + { binaryPath, env: { PATH: "" } }, + ); + const update = capabilities.update; + expect(update).not.toBeNull(); + if (!update) return; + const result = NodeChildProcess.spawnSync(update.executable, update.args, { + env: { PATH: "" }, + encoding: "utf8", + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(result.stdout).toBe("upgrade"); + }).pipe(Effect.scoped), + ); + + it.each([ + "/Users/example/.local/bin/native-package-tool", + "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe", + ])("preserves a configured native executable path: %s", (binaryPath) => { + expect(nativePackageToolUpdate.resolve({ binaryPath }).update?.executable).toBe(binaryPath); + }); + + it("uses the resolved launcher when its symlink target identifies a native install", () => { + const launcher = "/custom tools/native-launcher"; + const capabilities = nativePackageToolUpdate.resolve({ + binaryPath: launcher, + resolvedCommandPath: launcher, + realCommandPath: "/Users/example/.local/bin/native-package-tool", + }); + expect(capabilities.update?.executable).toBe(launcher); + }); + it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { expect( nativePackageToolUpdate.resolve({ diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 14d17cf365c3..c7a0bb112b69 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -209,6 +209,7 @@ function makeHomebrewProviderMaintenanceCapabilities( function makeNativeProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, + commandPath: string, ): ProviderMaintenanceCapabilities | null { if (!definition.nativeUpdate) { return null; @@ -217,7 +218,7 @@ function makeNativeProviderMaintenanceCapabilities( return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, - updateExecutable: definition.nativeUpdate.executable, + updateExecutable: commandPath, updateArgs: definition.nativeUpdate.args, updateLockKey: definition.nativeUpdate.lockKey, }); @@ -297,7 +298,7 @@ export function resolvePackageManagedProviderMaintenance( commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) ) { return ( - makeNativeProviderMaintenanceCapabilities(definition) ?? + makeNativeProviderMaintenanceCapabilities(definition, resolvedCommandPath) ?? makeNpmGlobalProviderMaintenanceCapabilities(definition) ); } From 720e126b761ba0da112d8c68b589d20ab98a7eb0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 17:53:57 -0700 Subject: [PATCH 16/69] fix(web): restore composer expansion after tool calls (#9782) --- apps/web/src/components/ChatView.tsx | 5 + apps/web/src/components/chat/ChatComposer.tsx | 36 +++++--- .../components/chat/MessagesTimeline.test.tsx | 92 ++++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 71 +++++++++----- .../chat/useComposerFocusState.test.tsx | 87 ++++++++++++++++++ .../components/chat/useComposerFocusState.ts | 23 +++++ 6 files changed, 274 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerFocusState.test.tsx create mode 100644 apps/web/src/components/chat/useComposerFocusState.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbeb3aa7f72..b79c291fbdac 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4757,6 +4757,10 @@ export default function ChatView(props: ChatViewProps) { requestAnimationFrame(() => positionAnchor(12)); }, []); + const onToolOutputCollapsedAtEnd = useCallback(() => { + composerRef.current?.restoreAfterTimelineReachedEnd(); + }, []); + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( !isAtEnd && @@ -7828,6 +7832,7 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} + onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5074d8a27c8e..2b19d6ec26ba 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,6 +88,7 @@ import { import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerMenuState } from "./useComposerMenuState"; +import { useComposerFocusState } from "./useComposerFocusState"; import { ComposerTasksBadge, ComposerTasksContent, @@ -1104,7 +1105,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - /** Undo only a scroll-triggered collapse when the timeline returns to its live edge. */ + /** Expand the desktop composer at the timeline end without taking focus. */ restoreAfterTimelineReachedEnd: () => void; addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; @@ -1787,8 +1788,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); - const [isComposerFocused, setIsComposerFocused] = useState(false); - const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); + const isMobileViewport = useMediaQuery("max-sm"); + const { + isComposerFocused, + setIsComposerFocused, + isComposerScrollCollapsed, + setIsComposerScrollCollapsed, + restoreAfterTimelineReachedEnd, + } = useComposerFocusState(isMobileViewport); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1800,7 +1807,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key: 0, active: false, }); - const isMobileViewport = useMediaQuery("max-sm"); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const isComposerCollapsedMobile = @@ -2351,7 +2357,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); setIsComposerScrollCollapsed(false); - }, [draftId, activeThreadId, promptRef]); + }, [draftId, activeThreadId, promptRef, setIsComposerScrollCollapsed]); // ------------------------------------------------------------------ // Footer compact layout observation @@ -2502,7 +2508,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) COMPOSER_SCROLL_GESTURE_RESET_MS, ); setIsComposerScrollCollapsed(false); - }, []); + }, [setIsComposerScrollCollapsed]); const onPromptChange = useCallback( ( @@ -2789,7 +2795,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.blur(); } setIsComposerFocused(false); - }, [isMobileViewport]); + }, [isMobileViewport, setIsComposerFocused]); const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; @@ -2949,7 +2955,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) mobileComposerExpandInFlightRef.current = false; }); }); - }, []); + }, [setIsComposerFocused]); // ------------------------------------------------------------------ // Callbacks: command key @@ -3687,7 +3693,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!canScrollCollapseComposer) { setIsComposerScrollCollapsed(false); } - }, [canScrollCollapseComposer]); + }, [canScrollCollapseComposer, setIsComposerScrollCollapsed]); // Returning to the window re-fires focus on the element that already held // it. That focus arrives after the window's own event, so a window focus @@ -3776,6 +3782,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) canTrackComposerScrollGesture, getTimelineScrollableNode, isTimelineAtLogicalEnd, + setIsComposerScrollCollapsed, ]); const restingHiddenBlockCount = composerControlsInStrip ? restingControlsHiddenBlockCount : 0; @@ -4365,7 +4372,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } setIsComposerFocused(false); }); - }, [getTimelineScrollableNode, isMobileViewport]); + }, [getTimelineScrollableNode, isMobileViewport, setIsComposerFocused]); // A held collapse settles when the selection goes away, whether the user // clicked elsewhere, pressed Escape, or used the selection toolbar. @@ -4457,7 +4464,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } desktopOutsidePointerInFlightRef.current = false; }; - }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck]); + }, [isComposerFocused, isMobileViewport, scheduleComposerCollapseCheck, setIsComposerFocused]); useEffect(() => { return () => { @@ -4486,7 +4493,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsComposerFocused(true); } setIsComposerModelPickerOpen(true); - }, [composerControlsHidden]); + }, [composerControlsHidden, setIsComposerFocused, setIsComposerScrollCollapsed]); useImperativeHandle( composerRef, @@ -4497,9 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - restoreAfterTimelineReachedEnd: () => { - setIsComposerScrollCollapsed(false); - }, + restoreAfterTimelineReachedEnd, addDroppedFiles: (files: File[]) => { void addComposerAttachments(files); focusComposer(); @@ -4643,6 +4648,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, planModeUiEnabled, compactThreadContext, + restoreAfterTimelineReachedEnd, ], ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index cf2e79a78e53..16a0cbd83ce3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,9 +1,12 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; -import { createRef, type ReactNode, type Ref } from "react"; +import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { create, type ReactTestRenderer } from "react-test-renderer"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; +import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; +import { useComposerFocusState } from "./useComposerFocusState"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -237,6 +240,93 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it.each([ + { toolLifecycleStatus: "inProgress", isAtEnd: true }, + { toolLifecycleStatus: "inProgress", isAtEnd: false }, + { toolLifecycleStatus: "completed", isAtEnd: true }, + { toolLifecycleStatus: "completed", isAtEnd: false }, + ] as const)( + "restores the composer after closing $toolLifecycleStatus tool output only at the end: $isAtEnd", + async ({ toolLifecycleStatus, isAtEnd }) => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + const props = buildProps(); + let timelineIsAtEnd = isAtEnd; + props.listRef.current = { + getState: () => ({ isAtEnd: timelineIsAtEnd }), + getScrollableNode: () => null, + } as unknown as LegendListRef; + let isResting = true; + function ThreadProbe() { + const composer = useComposerFocusState(false); + useLayoutEffect(() => { + isResting = shouldUseRestingComposerLayout({ + isExistingThread: true, + isMobileViewport: false, + isFocused: composer.isComposerFocused, + isScrollCollapsed: composer.isComposerScrollCollapsed, + hasExpandedChrome: false, + collapseOnBlur: true, + }); + }); + return ( + + ); + } + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + const toggle = renderer!.root.findByProps({ "aria-expanded": false }); + await act(() => toggle.props.onClick()); + await flushFrame(); + await flushFrame(); + expect(isResting).toBe(true); + + timelineIsAtEnd = false; + await act(() => toggle.props.onClick()); + await flushFrame(); + timelineIsAtEnd = isAtEnd; + await flushFrame(); + expect(isResting).toBe(!isAtEnd); + } finally { + await act(() => renderer?.unmount()); + } + }, + ); + it("renders a feedback command and its pending response as normal thread messages", () => { const submission = { id: MessageId.make("feedback-command"), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 23807a3b0969..e399f38a01bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -206,7 +206,7 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorKey: string) => void; - onToggleWorkEntry: (anchorKey: string) => void; + onToggleWorkEntry: (anchorKey: string, collapsed: boolean) => void; workGroupViewState: WorkGroupViewState; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; @@ -230,7 +230,7 @@ interface WorkGroupViewState { const WorkGroupViewCtx = createContext<{ state: WorkGroupViewState; - onToggleEntry: () => void; + onToggleEntry: (collapsed: boolean) => void; } | null>(null); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER = ( @@ -333,6 +333,7 @@ interface MessagesTimelineProps { */ liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; + onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; @@ -379,6 +380,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, + onToolOutputCollapsedAtEnd, onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, @@ -413,24 +415,32 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); - const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => { - disclosureAnchorKeyRef.current = anchorKey; - setDisclosureToggleSettling(true); - if (disclosureSettleFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleFrameRef.current); - } - if (disclosureSettleSecondFrameRef.current !== null) { - cancelAnimationFrame(disclosureSettleSecondFrameRef.current); - } - disclosureSettleFrameRef.current = requestAnimationFrame(() => { - disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { - disclosureAnchorKeyRef.current = null; - setDisclosureToggleSettling(false); - disclosureSettleFrameRef.current = null; - disclosureSettleSecondFrameRef.current = null; + const suspendEndScrollMaintenanceForDisclosure = useCallback( + (anchorKey: string, collapsed = false) => { + disclosureAnchorKeyRef.current = anchorKey; + setDisclosureToggleSettling(true); + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); + } + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + disclosureSettleFrameRef.current = requestAnimationFrame(() => { + disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + disclosureAnchorKeyRef.current = null; + setDisclosureToggleSettling(false); + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; + // Wait for row measurement and the disclosure click's blur check. + // Closing output can reveal the end without a scroll event. + if (collapsed && resolveTimelineIsAtEnd(listRef.current?.getState()) === true) { + onToolOutputCollapsedAtEnd?.(); + } + }); }); - }); - }, []); + }, + [listRef, onToolOutputCollapsedAtEnd], + ); const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { const disclosureAnchorKey = disclosureAnchorKeyRef.current; @@ -463,7 +473,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const onToggleWorkGroup = useCallback( (groupId: string, anchorKey: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey); + suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId)); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { @@ -474,7 +484,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return next; }); }, - [suspendEndScrollMaintenanceForDisclosure], + [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], ); // An in-session interrupt leaves its turn expanded so the user keeps their @@ -1727,7 +1737,11 @@ const WorkGroupSection = memo(function WorkGroupSection({ isExpandedToolGroup: boolean; displayLabel?: string | undefined; }) { - const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx); + const { workspaceRoot, routeThreadKey, onToggleWorkEntry } = use(TimelineRowCtx); + const onToggleStandaloneEntry = useCallback( + (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), + [anchorKey, onToggleWorkEntry], + ); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), [groupedEntries, isExpandedToolGroup], @@ -1755,6 +1769,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={false} displayLabel={displayLabel} + onToggleEntry={onToggleStandaloneEntry} /> ))}
@@ -1791,7 +1806,10 @@ function ExpandedWorkGroupEntries({ } const groupView = useMemo( - () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }), + () => ({ + state: viewState, + onToggleEntry: (collapsed: boolean) => onToggleWorkEntry(anchorKey, collapsed), + }), [anchorKey, onToggleWorkEntry, viewState], ); const updateScrollFades = useCallback(() => { @@ -3045,6 +3063,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; // Before any hooks: spawn CTA rows render their own component. @@ -3057,6 +3076,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot={workspaceRoot} isExpandedToolGroupEntry={isExpandedToolGroupEntry} displayLabel={displayLabel} + onToggleEntry={props.onToggleEntry} /> ); }); @@ -3066,6 +3086,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot: string | undefined; isExpandedToolGroupEntry: boolean; displayLabel?: string | undefined; + onToggleEntry?: ((collapsed: boolean) => void) | undefined; }) { const { workEntry, workspaceRoot, isExpandedToolGroupEntry, displayLabel } = props; const { threadRef, onImageExpand } = use(TimelineRowCtx); @@ -3076,9 +3097,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const toggleExpanded = () => { const next = !expanded; if (groupView) { - groupView.onToggleEntry(); + groupView.onToggleEntry(!next); if (next) groupView.state.expandedEntries.add(workEntry.id); else groupView.state.expandedEntries.delete(workEntry.id); + } else { + props.onToggleEntry?.(!next); } setExpanded(next); }; diff --git a/apps/web/src/components/chat/useComposerFocusState.test.tsx b/apps/web/src/components/chat/useComposerFocusState.test.tsx new file mode 100644 index 000000000000..e4bf38430e85 --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocusState.test.tsx @@ -0,0 +1,87 @@ +import { act, useLayoutEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; +import { useComposerFocusState } from "./useComposerFocusState"; + +let root: Root; +let composer: ReturnType; +let isResting: boolean; + +function ComposerProbe({ isMobileViewport = false }: { isMobileViewport?: boolean }) { + const state = useComposerFocusState(isMobileViewport); + useLayoutEffect(() => { + composer = state; + isResting = shouldUseRestingComposerLayout({ + isExistingThread: true, + isMobileViewport, + isFocused: state.isComposerFocused, + isScrollCollapsed: state.isComposerScrollCollapsed, + hasExpandedChrome: false, + collapseOnBlur: true, + }); + }); + return null; +} + +beforeEach(async () => { + // The probe has no DOM output, but ReactDOM needs an event target. + const document = { + nodeType: 9, + addEventListener() {}, + removeEventListener() {}, + }; + const container = { + nodeType: 1, + tagName: "DIV", + namespaceURI: "http://www.w3.org/1999/xhtml", + ownerDocument: document, + addEventListener() {}, + removeEventListener() {}, + }; + vi.stubGlobal("document", document); + vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + root = createRoot(container as unknown as HTMLElement); + await act(() => root.render()); +}); + +afterEach(async () => { + await act(() => root.unmount()); + vi.unstubAllGlobals(); +}); + +describe("composer focus state", () => { + it("expands at the timeline end after a tool call takes focus", async () => { + await act(() => composer.setIsComposerFocused(true)); + expect(isResting).toBe(false); + + // A tool disclosure takes focus before the user scrolls through its output. + await act(() => composer.setIsComposerFocused(false)); + expect(isResting).toBe(true); + + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); + + await act(() => composer.setIsComposerFocused(false)); + expect(isResting).toBe(true); + }); + + it("can collapse again on the next scroll after returning to the end", async () => { + await act(() => composer.setIsComposerScrollCollapsed(true)); + expect(isResting).toBe(true); + + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(isResting).toBe(false); + + await act(() => composer.setIsComposerScrollCollapsed(true)); + expect(isResting).toBe(true); + }); + + it("does not expand the phone composer when the timeline reaches the end", async () => { + await act(() => root.render()); + await act(() => composer.restoreAfterTimelineReachedEnd()); + expect(composer.isComposerFocused).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/useComposerFocusState.ts b/apps/web/src/components/chat/useComposerFocusState.ts new file mode 100644 index 000000000000..c8858b33fa6b --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocusState.ts @@ -0,0 +1,23 @@ +import { useCallback, useState } from "react"; + +export function useComposerFocusState(isMobileViewport: boolean) { + const [isComposerFocused, setIsComposerFocused] = useState(false); + const [isComposerScrollCollapsed, setIsComposerScrollCollapsed] = useState(false); + + const restoreAfterTimelineReachedEnd = useCallback(() => { + setIsComposerScrollCollapsed(false); + // Restore the expanded layout after a timeline control takes focus too. + // This state holds the layout open without moving DOM focus to the editor. + if (!isMobileViewport) { + setIsComposerFocused(true); + } + }, [isMobileViewport]); + + return { + isComposerFocused, + setIsComposerFocused, + isComposerScrollCollapsed, + setIsComposerScrollCollapsed, + restoreAfterTimelineReachedEnd, + }; +} From 2dca7a1eddd27c823e4d9d0a316864c4ad9f95c5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 17:54:08 -0700 Subject: [PATCH 17/69] fix(client): explain possible network blocking for T3 Connect (#9783) --- .../settings/ConnectionsSettings.tsx | 4 +- .../src/authorization/service.ts | 7 ++- .../src/connection/errors.test.ts | 60 ++++++++++++++++++- .../client-runtime/src/connection/errors.ts | 11 ++-- .../src/connection/supervisor.test.ts | 32 +++++++++- .../src/connection/supervisor.ts | 8 ++- packages/client-runtime/src/errors/network.ts | 4 ++ .../src/relay/discovery.test.ts | 3 +- .../src/relay/managedRelay.test.ts | 40 ++++++++++++- .../client-runtime/src/relay/managedRelay.ts | 9 ++- .../client-runtime/src/rpc/session.test.ts | 52 +++++++++------- packages/client-runtime/src/rpc/session.ts | 25 +++++--- 12 files changed, 211 insertions(+), 44 deletions(-) create mode 100644 packages/client-runtime/src/errors/network.ts diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 1c4e034cf6a1..5a6f3ebd70b0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1524,7 +1524,9 @@ function SavedBackendListRow({ ) : null} {environment.connection.error && !resumingServerUpdate ? (

- {connectionStatusText(environment.connection)} + + {connectionStatusText(environment.connection)} + {errorTraceId ? ( ); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index b3fc1da8d0a3..9b0043ef90b4 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -69,8 +69,30 @@ function entry( } describe("visible pull request line-count targets", () => { + it("reuses counts supplied by the listing and only requests missing counts", () => { + const entries = [ + entry({ number: 1, additions: 12, deletions: 0 }), + entry({ number: 2, additions: 0, deletions: 7 }), + entry({ number: 3, additions: 0, deletions: 0 }), + ]; + const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); + const keys = pullRequestStatsKeysToRequest( + entriesByKey, + new Set(entries.map(pullRequestEntryKey)), + [], + new Map(), + ); + + expect(pullRequestStatsBatches(entriesByKey, keys)[0]?.input.refs).toEqual([ + { projectId: "project-1", repository: "pingdotgg/t3code", number: 3 }, + ]); + }); + it("does not request a row again after its received batch is pruned", () => { - const entries = [entry({ number: 1 }), entry({ number: 2 })]; + const entries = [ + entry({ additions: 0, deletions: 0, number: 1 }), + entry({ additions: 0, deletions: 0, number: 2 }), + ]; const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const firstKey = pullRequestEntryKey(entries[0]!); const secondKey = pullRequestEntryKey(entries[1]!); @@ -97,7 +119,9 @@ describe("visible pull request line-count targets", () => { }); it("drops historical rows after a long scroll so refresh stays bounded to the viewport", () => { - const entries = Array.from({ length: 500 }, (_, index) => entry({ number: index + 1 })); + const entries = Array.from({ length: 500 }, (_, index) => + entry({ additions: 0, deletions: 0, number: index + 1 }), + ); const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const batches = entries.map( (item) => pullRequestStatsBatches(entriesByKey, new Set([pullRequestEntryKey(item)]))[0]!, @@ -112,7 +136,9 @@ describe("visible pull request line-count targets", () => { }); it("keeps every per-environment batch within the stats contract limit", () => { - const entries = Array.from({ length: 501 }, (_, index) => entry({ number: index + 1 })); + const entries = Array.from({ length: 501 }, (_, index) => + entry({ additions: 0, deletions: 0, number: index + 1 }), + ); const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const batches = pullRequestStatsBatches( entriesByKey, @@ -125,9 +151,9 @@ describe("visible pull request line-count targets", () => { it("selects visible rows for date modes and every uncached row for size modes", () => { const entries = [ - entry({ number: 1 }), - entry({ number: 2 }), - entry({ number: 3, environmentId: "env-2" as EnvironmentId }), + entry({ additions: 0, deletions: 0, number: 1 }), + entry({ additions: 0, deletions: 0, number: 2 }), + entry({ additions: 0, deletions: 0, number: 3, environmentId: "env-2" as EnvironmentId }), ]; const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const [firstKey, secondKey] = entries.map(pullRequestEntryKey); @@ -162,7 +188,11 @@ describe("visible pull request line-count targets", () => { }); it("refreshes only visible rows unless size sorting needs every loaded row", () => { - const entries = [entry({ number: 1 }), entry({ number: 2 }), entry({ number: 3 })]; + const entries = [ + entry({ additions: 0, deletions: 0, number: 1 }), + entry({ additions: 0, deletions: 0, number: 2 }), + entry({ additions: 0, deletions: 0, number: 3 }), + ]; const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); const cachedStats = mergePullRequestDiffStats( @@ -198,7 +228,10 @@ describe("visible pull request line-count targets", () => { }); it("ignores a late refresh after the filter or stats policy changes", () => { - const entries = [entry({ number: 1 }), entry({ number: 2 })]; + const entries = [ + entry({ additions: 0, deletions: 0, number: 1 }), + entry({ additions: 0, deletions: 0, number: 2 }), + ]; const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const visibleKeys = new Set([pullRequestEntryKey(entries[1]!)]); const requestedScope = { key: "open", policy: "visible" } as const; @@ -217,7 +250,10 @@ describe("visible pull request line-count targets", () => { }); it("does not add request batches again while rows are active or cached", () => { - const entries = [entry({ number: 1 }), entry({ number: 2 })]; + const entries = [ + entry({ additions: 0, deletions: 0, number: 1 }), + entry({ additions: 0, deletions: 0, number: 2 }), + ]; const entriesByKey = new Map(entries.map((item) => [pullRequestEntryKey(item), item])); const first = pullRequestStatsRequestBatches({ entriesByKey, @@ -724,7 +760,7 @@ describe("default merge-readiness ranking", () => { ]); }); - it("puts the smallest measured change first inside a readiness tier", () => { + it("keeps readiness order stable when optional diff counts arrive", () => { const larger = entry({ number: 1, checksState: "passing", @@ -752,7 +788,14 @@ describe("default merge-readiness ranking", () => { expect( rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), - ).toEqual([2, 1, 3]); + ).toEqual([3, 1, 2]); + expect( + rankPullRequestsByMergeReadiness([ + larger, + { ...unknown, additions: 500, deletions: 200 }, + smaller, + ]).map((row) => row.number), + ).toEqual([3, 1, 2]); }); it("keeps authored work first and ranks each group by readiness", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index af1bd6ab4fbe..af71c934ad61 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -469,7 +469,11 @@ export function pullRequestStatsKeysToRequest( [...enteredKeys].filter((key) => { const entry = entriesByKey.get(key); return ( - entry !== undefined && !requested.has(key) && !statsByRow.has(pullRequestDiffStatKey(entry)) + entry !== undefined && + entry.additions === 0 && + entry.deletions === 0 && + !requested.has(key) && + !statsByRow.has(pullRequestDiffStatKey(entry)) ); }), ); @@ -1006,11 +1010,10 @@ export function rankPullRequestMatches( * verdict, then everything else still open. Drafts stay in that third tier because their author * has not made them mergeable yet. Finished work follows open work when all states are visible. A * known conflict is never ready, whatever its checks, review or state say, so it stays at the - * bottom. Smaller measured changes come first within a tier; recency only breaks a remaining tie. + * bottom. Recency breaks ties, so optional diff counts never move the queue beneath the reader. */ export function rankPullRequestsByMergeReadiness( entries: ReadonlyArray, - hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, ): ReadonlyArray { const tier = (entry: Entry) => { if (entry.mergeability === "conflicting") return 4; @@ -1023,10 +1026,7 @@ export function rankPullRequestsByMergeReadiness { const byTier = tier(left) - tier(right); if (byTier !== 0) return byTier; - const byMeasurement = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); - if (byMeasurement !== 0) return byMeasurement; - const bySize = left.additions + left.deletions - (right.additions + right.deletions); - return bySize !== 0 ? bySize : right.updatedAt.localeCompare(left.updatedAt); + return right.updatedAt.localeCompare(left.updatedAt); }); } @@ -1042,7 +1042,7 @@ export function sortPullRequestGroups( if (sort === "ready") { return searchText.trim().length === 0 - ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) + ? sortWithinGroups(rankPullRequestsByMergeReadiness) : groups; } if (sort === "updated") return groups; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index bcc22daa4a9d..48a558be8e0e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -292,7 +292,7 @@ function PullRequestsRouteView() { const search = Route.useSearch(); const sort = search.sort ?? "ready"; const statsPolicy: PullRequestStatsPolicy = - sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible"; + sort === "largest" || sort === "smallest" ? "eager" : "visible"; const navigate = useNavigate({ from: Route.fullPath }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); @@ -838,11 +838,7 @@ function PullRequestsRouteView() { } finally { setInvalidating(false); } - refreshList(); - baselineQuery.refresh(); - facetQuery.refresh(); - authoredQuery.refresh(); - reviewingQuery.refresh(); + refreshList(true); const visible = visibleStatsKeys.current; const batches = pullRequestStatsRefreshBatches({ requestedScope: requestedStatsScope, @@ -955,7 +951,7 @@ function PullRequestsRouteView() { environmentKey, scope: scopeKey, query: sentQuery, - data, + data: { ...data, entries: ordered?.key === filterKey ? ordered.entries : data.entries }, ...(partitions === undefined ? {} : { partitions }), }; }); @@ -1017,7 +1013,7 @@ function PullRequestsRouteView() { // `ordered` is declared above, ahead of the snapshot write, but grown here from this round's // own answer. useEffect(() => { - if (!answered) return; + if (!answered || listQuery.isPending || (listQuery.error && listQuery.data === null)) return; setOrdered((previous) => { if (previous === null || previous.key !== filterKey) { return { @@ -1045,7 +1041,15 @@ function PullRequestsRouteView() { // reads, so its order stands; a row that moved was updated, and moving is the news. return { key: filterKey, entries: rankPullRequestMatches(answered.entries, sentParsed.text) }; }); - }, [answered, filterKey, sentCursors, sentParsed.text]); + }, [ + answered, + filterKey, + sentCursors, + sentParsed.text, + listQuery.isPending, + listQuery.error, + listQuery.data, + ]); // Carrying on where the last answer stopped, and only raising the page size for the hosts that // could not say where that was. @@ -1081,11 +1085,20 @@ function PullRequestsRouteView() { // re-reads only its own slice, so the rows loaded before it would never see a merge, a close, // or a retitle. Going back to a single page long enough to cover everything on screen lets the // merge above bring every row up to date in place. - const refreshList = () => { + const refreshList = (includeRelated = false) => { + const related = includeRelated + ? [ + ...baselineTargets, + ...facetTargets, + ...partitionTargets.authored, + ...partitionTargets.reviewing, + ] + : []; if (sentCursors === null) { - listQuery.refresh(); + listQuery.refresh([...listTargets, ...related]); return; } + if (related.length > 0) listQuery.refresh(related); const loadedCount = ordered?.key === filterKey ? ordered.entries.length : pageSize; setPage({ key: filterKey, @@ -1117,9 +1130,7 @@ function PullRequestsRouteView() { // host's rate limit. useLiveRefresh( () => { - refreshList(); - authoredQuery.refresh(); - reviewingQuery.refresh(); + refreshList(true); }, { enabled: pullRequestsSupported }, ); @@ -1209,54 +1220,6 @@ function PullRequestsRouteView() { ); const scrollRef = useRef(null); - const sentinelRef = useRef(null); - useEffect(() => { - const sentinel = sentinelRef.current; - // A failed page must stop the observer. Retained rows keep the sentinel on screen, so - // re-arming it after a failure would ask for the next page again, forever. - // - // Rows on screen are also what makes reaching the sentinel mean anything: with none, it - // sits directly below the empty state and is always in view, so a search that matches - // nothing would page through the whole host on its own — one listing of every repository - // per step — while the reader looks at an empty page. With nothing to scroll past, the - // next page is asked for rather than assumed. - if ( - !sentinel || - entries.length === 0 || - listData?.truncated !== true || - listQuery.isPending || - listQuery.error !== null || - // The rows on screen belong to the previous question, so nothing about them says where - // this one carries on from. Growing the page under them would answer neither. - showingCarried || - // Asking past the cap is refused, which would strand the list on an error the retry - // could never clear, so growth stops here and the rest stays on the host. A continuation - // does not grow the page at all, so the cap does not apply to it. - (!canContinue && pageSize >= MAX_PAGE_SIZE) - ) { - return; - } - const observer = new IntersectionObserver( - (observed) => { - if (observed.some((entry) => entry.isIntersecting)) { - loadMore(); - } - }, - // Start the next page slightly before the sentinel is on screen. - { root: scrollRef.current, rootMargin: "240px" }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); - }, [ - entries.length, - filterKey, - canContinue, - listData?.truncated, - listQuery.error, - listQuery.isPending, - pageSize, - showingCarried, - ]); /** * The line counts, asked for once the rows are on screen. On GitHub they are forty per cent of @@ -1633,7 +1596,7 @@ function PullRequestsRouteView() { /> ) : firstLoad ? ( - ) : listQuery.error && listData === null ? ( + ) : listQuery.error && entries.length === 0 ? ( listQuery.refresh()} /> ) : carriedToNothing ? ( @@ -1699,22 +1662,33 @@ function PullRequestsRouteView() {

)} - {listQuery.error && listData !== null ? ( + {listQuery.error && entries.length > 0 ? (
- The latest request failed. Showing the last pull requests loaded. + {listQuery.error} Showing the last pull requests loaded.
) : null} {listData?.truncated && entries.length > 0 ? ( -
+
{loadingMore ? ( - Loading more + {sentCursors === null ? "Updating pull requests" : "Loading more"} - ) : null} + ) : canContinue || pageSize < MAX_PAGE_SIZE ? ( + + ) : ( + Narrow your search to find more pull requests. + )}
) : null} @@ -1979,10 +1953,7 @@ function PullRequestsRouteView() { // Merging, closing or reopening changes the row this panel was opened from, so // the list behind it is out of date the moment the host takes the action. onActed={() => { - refreshList(); - baselineQuery.refresh(); - authoredQuery.refresh(); - reviewingQuery.refresh(); + refreshList(true); }} /> @@ -2244,7 +2215,7 @@ function PullRequestsColumn({ return ( // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread. -
+
{/* A closed right panel leaves this column full-width, so the shared header reserves native window controls and hosts the controls strip itself: on desktop the header is a drag-region, and only a no-drag descendant wins @@ -2325,8 +2296,10 @@ function PullRequestsColumn({ content actually passing under the chrome fades. */}
-
- {searchInput} +
+
+ {searchInput} +
{sortMenu} {filtersMenu} {!condensed ? ( diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 99b2ef37c99a..eaec72066735 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -130,8 +130,8 @@ function createMergedEnvironmentQuery( (override?: ReadonlyArray>) => { const refreshTargets = override ?? (JSON.parse(key) as ReadonlyArray>); - for (const target of refreshTargets) { - appAtomRegistry.refresh(atomFor(target)); + for (const atom of new Set(refreshTargets.map(atomFor))) { + appAtomRegistry.refresh(atom); } }, [key], @@ -173,7 +173,7 @@ export interface MergedPullRequestListView { readonly data: MergedPullRequestList | null; readonly error: string | null; readonly isPending: boolean; - readonly refresh: () => void; + readonly refresh: (targets?: ReadonlyArray>) => void; } /** One listing per environment, merged into the single list the page renders. */ diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 6144d8507a9e..d0da18a7226a 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -75,8 +75,8 @@ export function pullRequestDetailToVcsStatus( } /** - * Every read shells out to the GitHub CLI, so results are reused for a short while and - * refreshed explicitly. Mutations run serially per environment: `gh` actions on the same + * Reopening a PR within a minute reuses detail and activity. Explicit refreshes and + * turn notifications still revalidate. Mutations run serially per environment: actions on the same * pull request are order-sensitive, and the detail view refetches after each one. */ export function createPullRequestEnvironmentAtoms( @@ -91,7 +91,7 @@ export function createPullRequestEnvironmentAtoms( const activity = createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:activity", tag: WS_METHODS.pullRequestsActivity, - staleTimeMs: 15_000, + staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }); return { @@ -118,7 +118,7 @@ export function createPullRequestEnvironmentAtoms( detail: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:detail", tag: WS_METHODS.pullRequestsDetail, - staleTimeMs: 15_000, + staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), activity, From c3caceade14ccda0686db0031aca79d7d38a71d0 Mon Sep 17 00:00:00 2001 From: Niklas Westman Date: Sat, 5 Sep 2026 03:10:46 +0200 Subject: [PATCH 20/69] perf(shared): skip duplicate PATH entries and per-probe tracing (#9618) Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- packages/shared/src/shell.test.ts | 121 ++++++++++++++++++++++++++++++ packages/shared/src/shell.ts | 12 ++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e3046c03abed..c98c1c452d4b 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,11 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { describe, expect, it, vi } from "vite-plus/test"; import { extractPathFromShellOutput, CommandAvailability, + CommandResolutionCache, type CommandAvailabilityChecker, isCommandAvailable, listLoginShellCandidates, @@ -375,6 +379,123 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { expect(result._tag).toBe("Failure"); }), ); + + // Records every path the scan stats, without ever reporting a match, so the + // walk runs to exhaustion and the probe set can be inspected. Assertions + // below count probes rather than naming paths: `Path` is the host's, so the + // separator differs between a Windows and a Linux CI runner. + const recordProbes = (env: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const probed: Array = []; + const result = yield* resolveCommandPath("definitely-not-installed", { env }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), + Effect.provide( + FileSystem.layerNoop({ + stat: (filePath) => + Effect.sync(() => { + probed.push(filePath); + return { type: "Directory" } as FileSystem.File.Info; + }), + }), + ), + Effect.result, + ); + + expect(result._tag).toBe("Failure"); + return probed; + }); + + it.effect("visits a repeated PATH directory only once", () => + Effect.gen(function* () { + const probed = yield* recordProbes({ + PATH: "C:\\bin;C:\\other;C:\\bin;C:\\other", + PATHEXT: ".COM;.EXE", + }); + + // Two directories, two extensions, upper and lowercase spellings. + expect(probed).toHaveLength(8); + expect(new Set(probed).size).toBe(probed.length); + }), + ); + + it.effect("still visits a PATH entry that differs only in case", () => + Effect.gen(function* () { + const probed = yield* recordProbes({ + PATH: "C:\\bin;C:\\BIN", + PATHEXT: ".COM;.EXE", + }); + + // Deliberately not folded together. Windows 10+ can mark a directory + // case-sensitive, so the two spellings are not provably one directory and + // skipping the second could hide a command that is really there. + expect(probed).toHaveLength(8); + }), + ); + + it.effect.each(["audit-command", "audit-command.CMD"])( + "resolves lowercase executable files for %s in a case-sensitive Windows PATH directory", + (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-case-sensitive-path-" }); + const executable = path.join(cwd, "audit-command.cmd"); + yield* fs.writeFileString(executable, "@echo off\n"); + + const resolved = yield* resolveCommandPath(command, { + env: { PATH: cwd, PATHEXT: ".CMD" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), + Effect.provideService(FileSystem.FileSystem, { + ...fs, + // Keep this case-sensitive fixture portable to case-insensitive hosts. + stat: (filePath) => + fs.stat(filePath === executable ? filePath : path.join(cwd, "missing")), + }), + ); + + expect(resolved).toBe(executable); + }), + ); + + it.effect("keeps cached misses until expiry while allowing explicit paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-path-cache-" }); + const executable = path.join(cwd, "appeared.CMD"); + const options = { env: { PATH: `${cwd};${cwd}`, PATHEXT: ".CMD" } }; + + expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe( + "Failure", + ); + yield* fs.writeFileString(executable, "@echo off\n"); + expect((yield* resolveCommandPath("appeared", options).pipe(Effect.result))._tag).toBe( + "Failure", + ); + expect(yield* resolveCommandPath(executable, options)).toBe(executable); + yield* TestClock.adjust("30 seconds"); + expect(yield* resolveCommandPath("appeared", options)).toBe(executable); + }).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ); + + it.effect("keeps upper and lowercase PATHEXT candidates", () => + Effect.gen(function* () { + const probed = yield* recordProbes({ + PATH: "C:\\bin", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }); + + expect(probed).toHaveLength(8); + expect(probed.filter((filePath) => /\.(COM|EXE|BAT|CMD)$/.test(filePath))).toHaveLength(4); + expect(probed.filter((filePath) => /\.(com|exe|bat|cmd)$/.test(filePath))).toHaveLength(4); + }), + ); }); effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 4c86c8886312..7d7a7d7b4f41 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -544,7 +544,8 @@ function cacheCommandResolution( }); } -const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( +// Trace each command lookup, not every candidate file it probes. +const isExecutableFile = Effect.fnUntraced(function* ( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, @@ -605,12 +606,15 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat return cached.resolvedPath; } + // Keep case variants: Windows can make PATH directories case-sensitive. const pathEntries: string[] = []; + const seenPathEntries = new Set(); for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); - if (pathEntry.length > 0) { - pathEntries.push(pathEntry); - } + if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; + + seenPathEntries.add(pathEntry); + pathEntries.push(pathEntry); } for (const pathEntry of pathEntries) { From 896fe82f2f191eb8ac0fd620e19c25dd48253592 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 4 Sep 2026 21:11:01 -0400 Subject: [PATCH 21/69] fix(web): preserve focus and prioritize picker shortcuts (#9795) --- apps/web/src/components/CommandPalette.tsx | 11 +++++--- .../src/components/CommandPaletteContent.tsx | 13 +++++++-- apps/web/src/components/LegacySidebar.tsx | 4 +-- apps/web/src/components/Sidebar.tsx | 6 ++-- .../src/components/ThreadTerminalDrawer.tsx | 28 ++++++++----------- .../components/chat/ModelPickerContent.tsx | 15 +++++----- docs/user/keyboard-focus.md | 9 ++++++ 7 files changed, 52 insertions(+), 34 deletions(-) create mode 100644 docs/user/keyboard-focus.md diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 06813228b8e4..27dddf00ee53 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -508,7 +508,10 @@ export function CommandPalette({ children }: { children: ReactNode }) { setOpen(open); }} > - {children} + {/* Block background focus calls for the entire time the palette is open. */} +
+ {children} +
group.items) .find((item) => item.shortcutCommand === command); if (matchingItem) { - event.preventDefault(); - event.stopPropagation(); executeItem(matchingItem); - return; } + return; } if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) { event.preventDefault(); diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index af3c1b671704..8c1a5b0e3c83 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; +import { type ComponentProps, type ReactNode, useLayoutEffect, useRef } from "react"; import { Command, CommandFooter, CommandInput, CommandPanel } from "./ui/command"; import { Kbd, KbdGroup } from "./ui/kbd"; @@ -33,11 +33,20 @@ export function CommandPaletteContent({ testId, ...commandProps }: CommandPaletteContentProps) { + const inputRef = useRef(null); + + // Direct-open flows replace the initial palette view after the dialog has + // already moved focus. Reclaim it when the replacement input mounts so + // typing cannot continue in the composer behind the modal. + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + return (
- + {inputAccessory}
{children} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index ea910905efe0..650ad12bebcb 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -172,7 +172,7 @@ import { useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, @@ -3510,7 +3510,7 @@ export default function LegacySidebar() { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { const shortcutContext = getCurrentSidebarShortcutContext(); - if (event.defaultPrevented || event.repeat) { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { return; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2902e238e01e..2e962810d621 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -104,7 +104,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { openCommandPalette } from "../commandPaletteBus"; +import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; @@ -3505,7 +3505,9 @@ export default function Sidebar() { ); useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) return; + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { + return; + } const command = resolveShortcutCommand(event, keybindings, { platform: navigator.platform, context: { diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 89cdc3649acd..9f4956aae682 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -516,7 +516,10 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (autoFocus && visibleRef.current) window.requestAnimationFrame(() => terminal.focus()); + // Startup may finish after the user has returned to the composer. + if (visibleRef.current && mount.contains(document.activeElement)) { + terminal.focus(); + } const dismissSelectionAction = (supersede = false) => { const ownsMenu = @@ -870,10 +873,10 @@ export function TerminalViewport({ return () => { cancelled = true; + const hadFocus = mount.contains(document.activeElement); teardown?.(); + if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; - // autoFocus is intentionally omitted; - // it is only read at mount time and must not trigger terminal teardown/recreation. }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { @@ -904,24 +907,14 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus && visibleRef.current) { - window.requestAnimationFrame(() => { - terminal.focus(); - }); - } previousSessionRef.current = current; - }, [autoFocus, terminalOutput, terminalError, terminalStatus, terminalVersion]); + }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { if (!autoFocus || !visible) return; - const terminal = terminalRef.current; - if (!terminal) return; - const frame = window.requestAnimationFrame(() => { - terminal.focus(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; + // Claim focus when requested, then hand it to the terminal once ready only + // if the user has not focused something else in the meantime. + (terminalRef.current ?? containerRef.current)?.focus(); }, [autoFocus, focusRequestId, visible]); useEffect(() => { @@ -944,6 +937,7 @@ export function TerminalViewport({ return (
); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 8880369a18c9..63dea7844c46 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -5,6 +5,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; +import { useAtomValue } from "@effect/atom-react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { ChevronRightIcon, SearchIcon } from "lucide-react"; @@ -26,6 +27,8 @@ import { ComboboxListVirtualized, } from "../ui/combobox"; import { ModelEsque } from "./providerIconUtils"; +import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { primaryServerKeybindingsAtom } from "../../state/server"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -217,10 +220,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { : [], ), ); - const keybindings = useMemo( - () => providedKeybindings ?? [], - [providedKeybindings], - ); + const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); + const keybindings = providedKeybindings ?? serverKeybindings; const updateSettings = useUpdateClientSettings(); const focusSearchInput = useCallback(() => { @@ -678,7 +679,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.defaultPrevented || event.repeat) { + if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) { return; } @@ -690,6 +691,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (jumpIndex === null) { return; } + event.preventDefault(); + event.stopPropagation(); const targetModelKey = modelJumpModelKeys[jumpIndex]; if (!targetModelKey) { @@ -699,8 +702,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return; } - event.preventDefault(); - event.stopPropagation(); handleModelSelect(model.slug, model.instanceId); }; diff --git a/docs/user/keyboard-focus.md b/docs/user/keyboard-focus.md new file mode 100644 index 000000000000..50b9c87c24f3 --- /dev/null +++ b/docs/user/keyboard-focus.md @@ -0,0 +1,9 @@ +# Keyboard focus + +The command palette keeps focus while open. Closing it returns focus to the composer. +While the palette or model picker is open, number shortcuts select its entries instead of +switching threads. Model shortcuts work in Settings as well as the composer. +See [Keybindings](./keybindings.md) to customize these shortcuts. + +If you return to typing while a terminal is starting, the composer keeps focus when the terminal +becomes ready. Opening or switching to a terminal explicitly still focuses it. From 82f64cd8d046ab4ee8588f7bcbc1e66a4a0c80fe Mon Sep 17 00:00:00 2001 From: Amirali Beigi <43336552+amiralibg@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:43:43 +0330 Subject: [PATCH 22/69] fix(skills): support names beginning with digits (#9244) Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/nativeMarkdownText.ts | 3 +- .../mobile/src/lib/nativeMarkdownText.test.ts | 23 +++++++++++ .../Drivers/ClaudeSkillDispatch.test.ts | 16 +++++++- .../provider/Drivers/ClaudeSkillDispatch.ts | 3 +- .../src/provider/Drivers/CursorSkills.ts | 5 ++- .../provider/Layers/CursorProvider.test.ts | 18 +++++++++ apps/web/src/components/ChatMarkdown.test.tsx | 40 +++++++++++++++++++ .../src/components/chat/SkillInlineText.tsx | 3 +- apps/web/src/composer-editor-mentions.test.ts | 23 +++++++++++ .../shared/src/composerInlineTokens.test.ts | 20 ++++++++++ packages/shared/src/composerInlineTokens.ts | 10 ++++- 11 files changed, 157 insertions(+), 7 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 2b39ac201599..4c8a6c4d7cd5 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -195,7 +195,8 @@ function appendRun( return runs; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_TOKEN_REGEX = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 1e7cb5f3164e..c3951c8d81f0 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -226,6 +226,29 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("decorates known skill references that begin with a digit", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $2spec for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$2spec", + role: "body", + skillName: "2spec", + skillLabel: "2Spec", + }, + { text: " for this.", role: "body" }, + ]); + }); + it("decorates known skill references inside blockquotes", () => { const node: MarkdownNode = { type: "blockquote", diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index 99074c8b07ed..e6e6da9d5e9e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; -const SKILLS = new Set(["implement", "review", "re-release-version"]); +const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]); describe("planClaudeSkillDispatch", () => { it("leaves a prompt without a known skill untouched", () => { @@ -27,6 +27,14 @@ describe("planClaudeSkillDispatch", () => { }); }); + it("dispatches a known skill whose name begins with a digit", () => { + expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({ + leadingText: "use", + commandText: "/2spec for this", + skillName: "2spec", + }); + }); + it("dispatches the last mention and rewrites earlier ones inline", () => { expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({ leadingText: "/review the diff, then", @@ -38,4 +46,10 @@ describe("planClaudeSkillDispatch", () => { it("ignores a dollar token glued to other text", () => { expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined(); }); + + it("ignores currency amounts and compact monetary expressions", () => { + const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M"]); + expect(planClaudeSkillDispatch("pay $20 tomorrow", skillsWithCurrency)).toBeUndefined(); + expect(planClaudeSkillDispatch("budget is $20k tomorrow", skillsWithCurrency)).toBeUndefined(); + }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index a008e0f9ec9b..27e7dcf68bdf 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -29,7 +29,8 @@ * (`packages/shared/src/composerInlineTokens.ts`), so a rendered chip and a * dispatched skill are always the same set. */ -const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_MENTION_PATTERN = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; export interface ClaudeSkillDispatch { /** Text before the dispatched mention, or `undefined` when it opens the prompt. */ diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts index 7b0637267c80..599dc712d46a 100644 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -19,8 +19,9 @@ import * as Schema from "effect/Schema"; import { parse as parseYamlDocument } from "yaml"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; -const SKILL_MENTION_PATTERN = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; -const HAS_SKILL_MENTION_PATTERN = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=\s|$)/; +const SKILL_MENTION_PATTERN = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source); const MAX_SKILL_DEPTH = 10; const MAX_SKILL_BYTES = FileSystem.Size(1_000_000); const MAX_SKILL_SCAN_ENTRIES = 10_000; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index a01958dcccac..78edd8acbd45 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -455,6 +455,24 @@ describe("Cursor skills", () => { "please /review this", ); }); + + it("detects and invokes digit-leading Cursor skills without rewriting money", () => { + const names = new Set(["2spec", "20k", "100M", "1e6"]); + // Repeated presence checks must not carry a global-regex cursor. + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); + expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); + for (const text of [ + "pay $20 tomorrow", + "budget $20k here", + "cost $100M total", + "limit $1e6 here", + ]) { + expect(hasCursorSkillMention(text)).toBe(false); + expect(rewriteCursorSkillMentions(text, names)).toBe(text); + } + }); }); describe("getCursorFallbackModels", () => { diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 28ac42888eef..9762506531a8 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -293,6 +293,46 @@ describe("hasMarkdownFilePrimaryAction", () => { }); }); +describe("ChatMarkdown skill chips", () => { + it("updates digit-leading skill labels when discovered skills change", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const text = "Use $2spec with a $20k budget."; + try { + await act(async () => { + renderer = create(); + }); + const mounted = renderer!; + const labels = (label: string) => + mounted.root.findAllByType("span").filter((node) => node.children.includes(label)); + expect(labels("2Spec")).toHaveLength(0); + + await act(async () => { + mounted.update( + , + ); + }); + expect(labels("2Spec")).toHaveLength(1); + expect(labels("MoneySkill")).toHaveLength(0); + + await act(async () => { + mounted.update(); + }); + expect(labels("2Spec")).toHaveLength(0); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + describe("ChatMarkdown file option chips", () => { it("keeps the fallback button text selectable", () => { const html = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 6d026ea58cce..b6e398539133 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -10,7 +10,8 @@ import { } from "../composerInlineChip"; import { cn } from "~/lib/utils"; -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +const SKILL_TOKEN_REGEX = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g; type InlineSkill = Pick; diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 33f37af6802b..337fe7b8ae64 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -198,6 +198,29 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); + it("splits digit-leading skill tokens into skill segments", () => { + expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([ + { type: "text", text: "Use " }, + { type: "skill", name: "2spec" }, + { type: "text", text: " please" }, + ]); + }); + + it("keeps digits-only dollar amounts and compact monetary expressions as text", () => { + expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([ + { type: "text", text: "I'll pay $20 tomorrow" }, + ]); + expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([ + { type: "text", text: "Budget is $20k tomorrow" }, + ]); + expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([ + { type: "text", text: "Cost is $100M total" }, + ]); + expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([ + { type: "text", text: "Limit is $1e6 here" }, + ]); + }); + it("does not convert an incomplete trailing skill token", () => { expect(splitPromptIntoComposerSegments("Use $review-follow-up")).toEqual([ { type: "text", text: "Use $review-follow-up" }, diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 81fd6add2056..5ce8f6281184 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -31,6 +31,26 @@ describe("collectComposerInlineTokens", () => { ]); }); + it("collects skill names that begin with a digit", () => { + expect(collectComposerInlineTokens("Use $2spec next")).toEqual([ + { + type: "skill", + value: "2spec", + source: "$2spec", + start: 4, + end: 10, + }, + ]); + }); + + it("leaves digits-only dollar amounts and compact monetary expressions as text", () => { + expect(collectComposerInlineTokens("I'll pay $20 tomorrow")).toEqual([]); + expect(collectComposerInlineTokens("Budget is $1_000 total")).toEqual([]); + expect(collectComposerInlineTokens("Budget is $20k tomorrow")).toEqual([]); + expect(collectComposerInlineTokens("Cost is $100M total")).toEqual([]); + expect(collectComposerInlineTokens("Limit is $1e6 here")).toEqual([]); + }); + it("does not convert incomplete trailing tokens", () => { expect(collectComposerInlineTokens("Use $ui")).toEqual([]); expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index 11a5accf37b7..bb39dd599475 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -18,7 +18,15 @@ export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +/** + * A skill name may start with a digit, but compact monetary amounts and + * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose: + * the composer chips any matched `$name` token, known or not. Tokens beginning + * with digits must not match numbers with currency/exponent suffixes, and must + * contain at least one letter. + */ +const SKILL_TOKEN_REGEX = + /(^|\s)\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s)/g; const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** * The label body is bounded rather than `*`. Unbounded, every whitespace in From 89ee69e4430b21ee14565abf5c34dae43f38c1d8 Mon Sep 17 00:00:00 2001 From: Shubh Date: Sat, 5 Sep 2026 07:04:11 +0530 Subject: [PATCH 23/69] fix(server): advertise truecolor in the integrated terminal (#7680) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/server/src/terminal/Manager.test.ts | 25 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index c99b5a000438..deea39631788 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1687,6 +1687,31 @@ it.layer( }), ); + it.effect.each(["linux", "darwin", "win32"] as const)( + "advertises truecolor before the PTY backend on %s without replacing explicit values", + (platform) => + Effect.gen(function* () { + for (const [parentColor, runtimeColor, expected] of [ + [undefined, undefined, "truecolor"], + ["", undefined, "truecolor"], + ["24bit", undefined, "24bit"], + ["24bit", "", "truecolor"], + ["24bit", "custom", "custom"], + ] as const) { + const env = Object.freeze({ COLORTERM: parentColor }); + const { manager, ptyAdapter } = yield* createManager(5, { + shellResolver: () => "/bin/sh", + env, + }).pipe(Effect.provide(withHostPlatform(platform))); + yield* manager.open( + openInput({ env: runtimeColor === undefined ? {} : { COLORTERM: runtimeColor } }), + ); + expect(ptyAdapter.spawnInputs[0]?.env.COLORTERM).toBe(expected); + expect(env.COLORTERM).toBe(parentColor); + } + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index fcfdc2bb26cc..f04e3c2d897b 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1270,6 +1270,10 @@ function createTerminalSpawnEnv( spawnEnv[key] = value; } } + // Both PTY backends feed truecolor-capable terminal clients. + if (spawnEnv.COLORTERM === undefined || spawnEnv.COLORTERM === "") { + spawnEnv.COLORTERM = "truecolor"; + } return stripAppImageRuntimeEnv(spawnEnv); } From 940e8233c227a186044078e99e45e1933eb525e4 Mon Sep 17 00:00:00 2001 From: Vitaly Iegorov Date: Sat, 5 Sep 2026 03:42:17 +0200 Subject: [PATCH 24/69] fix(claude): surface usage-limit pauses in the thread (#7165) Co-authored-by: Claude Fable 5 Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/provider/Layers/ClaudeAdapter.test.ts | 524 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 103 +++- docs/user/providers-claude.md | 8 + 3 files changed, 628 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index afea9a605084..84cc03b75a6d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -23,6 +23,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -3662,6 +3663,529 @@ describe("ClaudeAdapterLive", () => { ); }); + const observeUsageLimitEvents = (adapter: ClaudeAdapterShape, query: FakeClaudeQuery) => + Effect.gen(function* () { + const runtimeEvents: Array = []; + let receipt: Deferred.Deferred | undefined; + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if ( + receipt && + event.type === "session.state.changed" && + event.payload.reason === "api_retry:1/1" + ) { + yield* Deferred.succeed(receipt, undefined); + } + }), + ).pipe(Effect.forkChild); + const drainSdkMessages = Effect.gen(function* () { + receipt = yield* Deferred.make(); + // The heartbeat follows queued SDK messages without adding a warning. + query.emit({ + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 1, + retry_delay_ms: 0, + error_status: 429, + error: { type: "rate_limit_error" }, + session_id: "sdk-session-limit", + uuid: "usage-limit-drain", + } as unknown as SDKMessage); + yield* Deferred.await(receipt); + }); + return { runtimeEvents, runtimeEventsFiber, drainSdkMessages }; + }); + + it.effect("surfaces a rejected Claude usage limit once per turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + // resetsAt is epoch seconds, so the window reopens 4h 1m30s out. + const nowMs = yield* Clock.currentTimeMillis; + const rateLimitInfo = { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 4 * 60 * 60 + 90, + }; + const rejected = { + type: "rate_limit_event", + rate_limit_info: rateLimitInfo, + session_id: "sdk-session-limit", + uuid: "rate-limit-rejected", + }; + // Sibling fields drift while the window is parked, so the same rendered + // line can arrive more than once inside one turn. + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + // The repeat lands minutes later, so the remaining wait has visibly + // shrunk. Deduping on the rendered row would let that drift through. + yield* TestClock.adjust("5 minutes"); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + const usageLimitRows = () => + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")); + assert.equal(usageLimitRows().length, 1); + // A wait, not a wall clock: the server renders this row but clients read + // it from other timezones. Reading resetsAt as milliseconds would put the + // window minutes out instead of hours, so the hour also pins the scale. + assert.match( + usageLimitRows()[0] ?? "", + /^Claude usage limit reached\. This turn is paused until the 5-hour limit resets in 4h( \d{1,2}m)?\.$/, + ); + // The exact instant still rides along for clients that want to render it. + assert.deepEqual( + runtimeEvents.find((event) => event.type === "runtime.warning")?.payload.detail, + rateLimitInfo, + ); + // The raw telemetry event still flows for every copy. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + // Same window, drifting siblings: still the one pause. + harness.query.emit({ + ...rejected, + rate_limit_info: { ...rateLimitInfo, utilization: 0.99 }, + uuid: "rate-limit-rejected-drift", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.equal(usageLimitRows().length, 1); + + // Retrying inside the same window renders the identical line. Staying + // quiet there would put the new turn right back to a silent spin. + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-limit", + uuid: "result-limit", + } as unknown as SDKMessage); + yield* drainSdkMessages; + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "retry", attachments: [] }); + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(usageLimitRows().length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps allowed and malformed Claude rate-limit events out of the work log", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + // A turn is in flight, so silence here is the status filter doing its job + // rather than the between-turns guard. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitInfo of [ + { status: "allowed", rateLimitType: "five_hour", utilization: 0.4 }, + { status: "allowed_warning", rateLimitType: "five_hour", utilization: 0.9 }, + // Undeclared shape from an older/newer CLI must not take the session down. + undefined, + ]) { + harness.query.emit({ + type: "rate_limit_event", + ...(rateLimitInfo ? { rate_limit_info: rateLimitInfo } : {}), + session_id: "sdk-session-limit-ok", + uuid: `rate-limit-${rateLimitInfo?.status ?? "malformed"}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 2, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stays quiet when no turn is parked by the Claude limit", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The stream stays live between turns, so a reject can land with nothing + // to pause; claiming "this turn is paused" there would be a lie. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + utilization: 1, + resetsAt, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-idle", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + // Provisioned overage carries the request even though the base window + // rejected it, so the turn keeps running and needs no row. + for (const overage of [ + { overageStatus: "allowed" }, + { overageStatus: "allowed_warning" }, + { isUsingOverage: true }, + { overageInUse: true }, + ]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + utilization: 1, + ...overage, + }, + session_id: "sdk-session-idle", + uuid: "rate-limit-overage", + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "runtime.warning"), + [], + ); + // Idle and overage-covered events still reach the account telemetry stream. + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 5, + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("still surfaces the pause when overage is exhausted too", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 60 * 60; + // The overage-exhausted / out-of-credits shape: the base window and the + // overage it would have spent both reject, with neither isUsingOverage + // nor overageInUse set to say anything is still covered. Nothing is + // carrying the turn here, so staying quiet would be the silent spin + // this row exists to prevent. + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt, + overageStatus: "rejected", + }, + session_id: "sdk-session-dual-reject", + uuid: "rate-limit-dual-reject", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 1); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps one row per window when two Claude limits interleave", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const nowSeconds = Math.floor(nowMs / 1000); + const rejection = (rateLimitType: string, resetsAt: number, uuid: string) => ({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-interleaved", + uuid, + }); + + // One turn can park on more than one window; each deserves its own row, + // and a later repeat of an earlier window deserves none. + for (const message of [ + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour"), + rejection("seven_day", nowSeconds + 48 * 60 * 60, "limit-seven-day"), + rejection("five_hour", nowSeconds + 2 * 60 * 60, "limit-five-hour-repeat"), + ]) { + harness.query.emit(message as unknown as SDKMessage); + yield* drainSdkMessages; + } + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")) + .map((message) => message.replace(/ in \d+h( \d{1,2}m)?/, "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("re-announces a Claude limit for a synthetic turn", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + const rejected = { + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60, + }, + session_id: "sdk-session-synthetic", + uuid: "rate-limit-synthetic", + }; + harness.query.emit(rejected as unknown as SDKMessage); + yield* drainSdkMessages; + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-synthetic", + uuid: "result-synthetic", + } as unknown as SDKMessage); + yield* drainSdkMessages; + + // A background agent answering between prompts auto-starts a synthetic + // turn, which parks on the same window and needs its own row. + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-synthetic", + uuid: "assistant-synthetic", + parent_tool_use_id: null, + message: { + id: "assistant-message-synthetic", + content: [{ type: "text", text: "Following up" }], + }, + } as unknown as SDKMessage); + yield* drainSdkMessages; + harness.query.emit({ ...rejected, uuid: "rate-limit-synthetic-2" } as unknown as SDKMessage); + yield* drainSdkMessages; + + assert.equal(runtimeEvents.filter((event) => event.type === "runtime.warning").length, 2); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("drops an unusable Claude reset time, not the row or the session", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const [rateLimitType, resetsAt] of [ + ["five_hour", undefined], + // Implausibly far out once scaled to milliseconds: no credible wait. + ["seven_day", 1e20], + ] as const) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType, resetsAt }, + session_id: "sdk-session-limit-unusable", + uuid: `rate-limit-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => (event.type === "runtime.warning" ? event.payload.message : "")), + [ + "Claude usage limit reached. This turn is paused until the 5-hour limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day limit resets.", + ], + ); + // A throw inside the telemetry handler would tear the session down. + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "session.exited" || event.type === "runtime.error") + .map((event) => event.type), + [], + ); + // Still live enough to take the next turn. + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "still here", attachments: [] }); + + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("warns for unmapped Claude limits and names the probed model bucket", () => { + const scopedLimitNames = Ref.makeUnsafe({ overageIncluded: undefined }); + const harness = makeHarness({ scopedLimitNames }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const { runtimeEvents, runtimeEventsFiber, drainSdkMessages } = + yield* observeUsageLimitEvents(adapter, harness.query); + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: THREAD_ID, input: "hello", attachments: [] }); + + for (const rateLimitType of ["seven_day_overage_included", "future_window"]) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType }, + session_id: "sdk-session-unmapped-limit", + uuid: `rejected-${rateLimitType}`, + } as unknown as SDKMessage); + } + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated"), + [], + ); + + yield* Ref.set(scopedLimitNames, { overageIncluded: "Model A" }); + const nowMs = yield* Clock.currentTimeMillis; + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "seven_day_overage_included", + utilization: 1, + resetsAt: Math.floor(nowMs / 1000) + 3600, + }, + session_id: "sdk-session-unmapped-limit", + uuid: "rejected-probed-bucket", + } as unknown as SDKMessage); + yield* drainSdkMessages; + assert.deepEqual( + runtimeEvents + .filter((event) => event.type === "runtime.warning") + .map((event) => event.payload.message), + [ + "Claude usage limit reached. This turn is paused until the 7-day model limit resets.", + "Claude usage limit reached. This turn is paused until the limit resets.", + "Claude usage limit reached. This turn is paused until the 7-day Model A limit resets in 1h.", + ], + ); + assert.equal( + runtimeEvents.filter((event) => event.type === "account.rate-limits.updated").length, + 1, + ); + runtimeEventsFiber.interruptUnsafe(); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("consumes Claude command lifecycle notifications silently", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d295821d8dfa..a005f583066f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -14,6 +14,7 @@ import { type PermissionResult, type PermissionUpdate, type SDKMessage, + type SDKRateLimitInfo, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -327,6 +328,8 @@ interface ClaudeSessionContext { lastKnownTotalProcessedTokens: number | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + /** Limits already announced for the running turn, keyed `window:resetsAt`. */ + announcedUsageLimits: { turnId: string; keys: Set } | undefined; stopped: boolean; } @@ -503,6 +506,55 @@ function isInterruptedResult(result: SDKResultMessage): boolean { ); } +const CLAUDE_USAGE_LIMIT_WINDOWS = { + five_hour: "5-hour", + seven_day: "7-day", + seven_day_opus: "7-day Opus", + seven_day_sonnet: "7-day Sonnet", + seven_day_overage_included: "7-day model", + overage: "overage", +} satisfies Record, string>; + +/** Beyond this the reset time is not credible, so the row ships without a wait. */ +const CLAUDE_USAGE_LIMIT_MAX_WAIT_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * `resetsAt` is epoch seconds. The row states the remaining wait rather than a + * wall-clock time: this renders on the server, while the row is read on clients + * that may sit in another timezone and locale, and that carry their own + * timestamp preference. A wait reads the same everywhere. + */ +function describeClaudeUsageLimit( + info: SDKRateLimitInfo, + nowMs: number, + names: ClaudeScopedLimitNames, +): string { + const label = + info.rateLimitType === "seven_day_overage_included" && names.overageIncluded + ? `7-day ${names.overageIncluded}` + : info.rateLimitType + ? CLAUDE_USAGE_LIMIT_WINDOWS[info.rateLimitType] + : undefined; + const resetsAtMs = info.resetsAt === undefined ? undefined : info.resetsAt * 1000; + const waitMs = + resetsAtMs === undefined || !Number.isFinite(nowMs) ? undefined : resetsAtMs - nowMs; + const wait = + waitMs !== undefined && waitMs > 0 && waitMs <= CLAUDE_USAGE_LIMIT_MAX_WAIT_MS + ? formatClaudeUsageLimitWait(waitMs) + : undefined; + return `Claude usage limit reached. This turn is paused until the ${ + label ? `${label} ` : "" + }limit resets${wait ? ` in ${wait}` : ""}.`; +} + +function formatClaudeUsageLimitWait(waitMs: number): string { + const totalMinutes = Math.ceil(waitMs / 60_000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${totalMinutes}m`; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + function asRuntimeItemId(value: string): RuntimeItemId { return RuntimeItemId.make(value); } @@ -3802,16 +3854,52 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (message.type === "rate_limit_event") { + const rateLimitInfo = message.rate_limit_info; + if (!rateLimitInfo) return; const names = options?.scopedLimitNames ? yield* Ref.get(options.scopedLimitNames) : { overageIncluded: undefined }; - const limits = claudeRateLimitEventToUpdate(message.rate_limit_info, names); - if (!limits) return; - yield* offerRuntimeEvent({ - ...base, - type: "account.rate-limits.updated", - payload: { limits }, - }); + const limits = claudeRateLimitEventToUpdate(rateLimitInfo, names); + if (limits) { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { limits }, + }); + } + // A rejected window parks the turn inside the SDK: no further messages + // arrive and no result lands, so without a row the thread just spins. + // Warnings (allowed_warning) still have headroom and stay quiet, an + // account spending provisioned overage keeps running despite the reject, + // and between turns there is no turn to report as paused. + if ( + rateLimitInfo.status === "rejected" && + rateLimitInfo.overageStatus !== "allowed" && + rateLimitInfo.overageStatus !== "allowed_warning" && + rateLimitInfo.isUsingOverage !== true && + rateLimitInfo.overageInUse !== true && + context.turnState !== undefined + ) { + // Tracked per turn as a set of limit identities, not as the rendered + // row: a parked window re-fires while the remaining wait shrinks, and a + // turn can park on more than one window, so a single slot would let an + // interleaved repeat through. A new turn — including a synthetic one — + // starts a fresh set and announces its pause again. + const turnId = context.turnState.turnId; + if (context.announcedUsageLimits?.turnId !== turnId) { + context.announcedUsageLimits = { turnId, keys: new Set() }; + } + const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`; + if (!context.announcedUsageLimits.keys.has(limitKey)) { + context.announcedUsageLimits.keys.add(limitKey); + const notice = describeClaudeUsageLimit( + rateLimitInfo, + Date.parse(stamp.createdAt), + names, + ); + yield* emitRuntimeWarning(context, notice, rateLimitInfo); + } + } return; } }); @@ -4695,6 +4783,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTotalProcessedTokens: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + announcedUsageLimits: undefined, stopped: false, }; yield* Ref.set(contextRef, context); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 707cb5493225..b43b58e52627 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -49,6 +49,14 @@ You can also send `/compact` in an existing conversation. Web and desktop offer a large older thread. See [commands and skills](./composer.md#commands-and-skills) for using composer commands. +## Usage limits + +If your Claude subscription runs out of usage mid-turn, the thread shows which +limit was reached and the remaining wait when Claude provides a reset time. +Claude Code holds the turn until that window reopens, so it can keep showing as +working. Wait for the reset, or stop the turn and continue later. The warning's +timestamp shows when the displayed wait started. + ## Skills Claude skills come from the config directory's `skills` folder and the project's From ce4712d5b04fb998f79fe132245289191147e5d5 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 19:06:21 -0700 Subject: [PATCH 25/69] fix: restore UX after performance improvements (#9799) Restore status feedback, code-view worker reuse, streaming highlighting recovery, safe outbox recovery, current HTML, and projected live-event budgets. Verified with browser checks, iOS outbox recovery, 281 focused tests, and full CI. CI uses Shivam's HTTPS mirror fix. Created with GPT-6 Astra (preview) in Codex. Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +- apps/marketing/src/lib/homeMotion.test.ts | 226 ++++++++++++++++++ apps/marketing/src/lib/homeMotion.ts | 176 ++++++++++++++ apps/marketing/src/pages/index.astro | 47 +++- .../features/threads/use-project-actions.ts | 7 +- apps/mobile/src/lib/attachmentUpload.ts | 7 +- .../state/pending-task-editor-writes.test.ts | 2 +- .../mobile/src/state/thread-outbox-manager.ts | 44 +++- .../src/state/thread-outbox-removal.test.ts | 2 +- .../mobile/src/state/thread-outbox-storage.ts | 33 ++- apps/mobile/src/state/thread-outbox.test.ts | 193 ++++++++++++--- apps/mobile/src/state/thread-outbox.ts | 4 - .../src/state/use-composer-drafts.test.ts | 87 ++++++- .../src/state/use-thread-outbox-drain.test.ts | 12 +- .../src/state/use-thread-outbox-drain.ts | 31 +-- apps/server/src/http.ts | 16 +- .../src/orchestration/LiveStreamBudget.ts | 2 +- .../orchestration/ThreadLiveEventCoalescer.ts | 5 +- apps/server/src/server.test.ts | 168 +++++++++++-- apps/server/src/ws.ts | 38 ++- apps/web/src/components/ChatMarkdown.test.tsx | 44 ++++ apps/web/src/components/ChatMarkdown.tsx | 5 +- .../src/components/DiffWorkerPoolProvider.tsx | 108 ++++++--- .../src/components/RenderErrorBoundary.tsx | 31 ++- .../chat/ComposerActivityStatus.tsx | 5 +- .../chat/ComposerServerUpdateStatus.tsx | 9 +- .../src/components/chat/MessagesTimeline.tsx | 53 +++- .../diffs/StyledDiffCodeView.test.tsx | 51 +++- apps/web/src/index.css | 83 +++++++ apps/web/src/lib/visibleAnimation.test.ts | 154 ++++++++++++ apps/web/src/lib/visibleAnimation.ts | 64 +++++ 31 files changed, 1511 insertions(+), 204 deletions(-) create mode 100644 apps/marketing/src/lib/homeMotion.test.ts create mode 100644 apps/marketing/src/lib/homeMotion.ts create mode 100644 apps/web/src/lib/visibleAnimation.test.ts create mode 100644 apps/web/src/lib/visibleAnimation.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..fba55f53aaf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,9 @@ jobs: run: vpr typecheck - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Build desktop pipeline run: vp run build:desktop @@ -89,7 +91,9 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Install browser secret helper build libraries - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + run: | + sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources + sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/apps/marketing/src/lib/homeMotion.test.ts b/apps/marketing/src/lib/homeMotion.test.ts new file mode 100644 index 000000000000..42f7775a0157 --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { startHomeMotion } from "./homeMotion"; + +class ElementStub extends EventTarget { + properties = new Map(); + style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; + children: ElementStub[] = []; + scrollLeft = 0; + scrollWidth = 1_200; + clientWidth = 400; + matches = () => false; + contains = (target: EventTarget | null) => + target === this || (target instanceof ElementStub && this.children.includes(target)); + querySelectorAll = () => this.children; + getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); + scrollTo = vi.fn((options: ScrollToOptions) => { + this.scrollLeft = options.left ?? this.scrollLeft; + }); +} + +let observers: ObserverStub[] = []; +class ObserverStub { + constructor(private readonly callback: IntersectionObserverCallback) { + observers.push(this); + } + observe = vi.fn(); + disconnect = vi.fn(); + report(target: ElementStub, isIntersecting: boolean) { + this.callback( + [{ target, isIntersecting } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } +} + +let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); +let viewport = new EventTarget(); +let reduced = Object.assign(new EventTarget(), { matches: false }); +let fine = Object.assign(new EventTarget(), { matches: true }); +let frames = new Map(); +let dispose: (() => void) | undefined; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + observers = []; + frames = new Map(); + page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); + viewport = new EventTarget(); + reduced = Object.assign(new EventTarget(), { matches: false }); + fine = Object.assign(new EventTarget(), { matches: true }); + vi.stubGlobal("document", page); + vi.stubGlobal( + "window", + Object.assign(viewport, { + matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), + }), + ); + vi.stubGlobal("Node", ElementStub); + vi.stubGlobal("IntersectionObserver", ObserverStub); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); +}); + +afterEach(() => { + dispose?.(); + dispose = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function fixture() { + const hero = new ElementStub(); + const field = new ElementStub(); + const mark = new ElementStub(); + const otherMark = new ElementStub(); + field.children = [mark, otherMark]; + const endorsements = new ElementStub(); + const caret = new ElementStub(); + dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< + typeof startHomeMotion + >[0]); + return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; +} + +function movePointer(hero: ElementStub, x = 400, y = 600) { + hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); +} + +describe("homepage motion", () => { + it("gates each mark and caret and batches pointer input into one frame", () => { + const { hero, field, mark, otherMark, caret, observer } = fixture(); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + observer.report(mark, true); + observer.report(caret, true); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("running"); + + movePointer(hero, 100, 100); + movePointer(hero); + expect(frames.size).toBe(1); + expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); + const [id, callback] = [...frames][0]!; + frames.delete(id); + callback(0); + expect(field.properties.get("--px")).toBe("18.0px"); + expect(field.properties.get("--py")).toBe("14.0px"); + + movePointer(hero); + page.visibilityState = "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + expect(frames.size).toBe(0); + expect(field.properties.get("--px")).toBe("0px"); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + expect(caret.properties.get("--home-motion-state")).toBe("paused"); + page.visibilityState = "visible"; + page.dispatchEvent(new Event("visibilitychange")); + reduced.matches = true; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + reduced.matches = false; + fine.matches = false; + reduced.dispatchEvent(new Event("change")); + movePointer(hero); + expect(frames.size).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("running"); + }); + + it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { + const { endorsements, observer } = fixture(); + expect(vi.getTimerCount()).toBe(0); + observer.report(endorsements, true); + vi.advanceTimersByTime(7_999); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(16_001); + expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ + 400, 800, 400, + ]); + expect( + endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), + ).toBe(true); + + endorsements.clientWidth = endorsements.scrollWidth; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + endorsements.clientWidth = 400; + viewport.dispatchEvent(new Event("resize")); + expect(vi.getTimerCount()).toBe(1); + }); + + it("pauses paging for hover, focus, hidden content, and reduced motion", () => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + const changeVisibility = (visible: boolean) => { + page.visibilityState = visible ? "visible" : "hidden"; + page.dispatchEvent(new Event("visibilitychange")); + }; + const changeMotion = (matches: boolean) => { + reduced.matches = matches; + reduced.dispatchEvent(new Event("change")); + }; + const pauses = [ + [ + () => endorsements.dispatchEvent(new Event("pointerenter")), + () => endorsements.dispatchEvent(new Event("pointerleave")), + ], + [ + () => endorsements.dispatchEvent(new Event("focusin")), + () => + endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), + ], + [() => changeVisibility(false), () => changeVisibility(true)], + [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], + [() => changeMotion(true), () => changeMotion(false)], + ] as const; + for (const [pause, resume] of pauses) { + pause(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(16_000); + resume(); + expect(vi.getTimerCount()).toBe(1); + } + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + vi.advanceTimersByTime(8_000); + expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); + endorsements.dispatchEvent(new Event("pointerenter")); + expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { + const { endorsements, observer } = fixture(); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event(event)); + observer.report(endorsements, false); + observer.report(endorsements, true); + endorsements.dispatchEvent(new Event("pointerleave")); + viewport.dispatchEvent(new Event("resize")); + vi.advanceTimersByTime(60_000); + expect(vi.getTimerCount()).toBe(0); + expect(endorsements.scrollTo).not.toHaveBeenCalled(); + }); + + it("cancels pending work and ignores events after cleanup", () => { + const { hero, mark, endorsements, observer } = fixture(); + observer.report(mark, true); + observer.report(endorsements, true); + movePointer(hero); + dispose?.(); + observer.report(mark, true); + movePointer(hero); + reduced.dispatchEvent(new Event("change")); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + expect(mark.properties.get("--home-motion-state")).toBe("paused"); + }); +}); diff --git a/apps/marketing/src/lib/homeMotion.ts b/apps/marketing/src/lib/homeMotion.ts new file mode 100644 index 000000000000..5322eae4406d --- /dev/null +++ b/apps/marketing/src/lib/homeMotion.ts @@ -0,0 +1,176 @@ +/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ +export function startHomeMotion({ + hero, + field, + endorsements, + caret, +}: { + hero: HTMLElement; + field: HTMLElement; + endorsements: HTMLElement; + caret: HTMLElement; +}) { + if (typeof IntersectionObserver === "undefined") return () => {}; + + const marks = Array.from(field.querySelectorAll(".hero-float-mark")); + const visible = new Set(); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + const finePointer = window.matchMedia("(pointer: fine)"); + const events = new AbortController(); + const eventOptions = { signal: events.signal }; + let disposed = false; + let hovered = endorsements.matches(":hover"); + let focused = endorsements.contains(document.activeElement); + let userControlled = false; + let direction = 1; + let automaticScroll = false; + let pageTimer: ReturnType | undefined; + let pointerFrame: number | undefined; + let pointer: { x: number; y: number } | null = null; + + const canMove = (element: Element) => + !disposed && + visible.has(element) && + document.visibilityState === "visible" && + !reducedMotion.matches; + const canParallax = () => finePointer.matches && marks.some(canMove); + const canPage = () => + canMove(endorsements) && + !hovered && + !focused && + !userControlled && + endorsements.scrollWidth > endorsements.clientWidth; + + function resetPointer() { + if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); + pointerFrame = undefined; + pointer = null; + field.style.setProperty("--px", "0px"); + field.style.setProperty("--py", "0px"); + } + + function updatePaging() { + if (canPage()) { + pageTimer ??= setTimeout(advancePage, 8_000); + return; + } + if (pageTimer !== undefined) clearTimeout(pageTimer); + pageTimer = undefined; + if (automaticScroll) { + automaticScroll = false; + endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); + } + } + + function advancePage() { + pageTimer = undefined; + if (!canPage()) return; + const end = endorsements.scrollWidth - endorsements.clientWidth; + const current = endorsements.scrollLeft; + if (current >= end - 1) direction = -1; + else if (current <= 1) direction = 1; + automaticScroll = true; + endorsements.scrollTo({ + left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), + behavior: "smooth", + }); + updatePaging(); + } + + function update() { + for (const mark of marks) { + mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); + } + caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); + const parallax = canParallax(); + field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); + if (!parallax) resetPointer(); + updatePaging(); + } + + const observer = new IntersectionObserver((entries) => { + if (disposed) return; + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target); + else visible.delete(entry.target); + } + update(); + }); + for (const element of [...marks, endorsements, caret]) observer.observe(element); + + hero.addEventListener( + "pointermove", + (event) => { + if (!canParallax()) return; + pointer = { x: event.clientX, y: event.clientY }; + pointerFrame ??= requestAnimationFrame(() => { + pointerFrame = undefined; + if (!pointer || !canParallax()) return; + const bounds = hero.getBoundingClientRect(); + if (bounds.width === 0 || bounds.height === 0) return; + field.style.setProperty( + "--px", + `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, + ); + field.style.setProperty( + "--py", + `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, + ); + }); + }, + eventOptions, + ); + hero.addEventListener("pointerleave", resetPointer, eventOptions); + endorsements.addEventListener( + "pointerenter", + () => { + hovered = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "pointerleave", + () => { + hovered = false; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusin", + () => { + focused = true; + updatePaging(); + }, + eventOptions, + ); + endorsements.addEventListener( + "focusout", + (event) => { + focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); + updatePaging(); + }, + eventOptions, + ); + const takeControl = () => { + userControlled = true; + updatePaging(); + }; + endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); + endorsements.addEventListener("pointerdown", takeControl, eventOptions); + endorsements.addEventListener("keydown", takeControl, eventOptions); + document.addEventListener("visibilitychange", update, eventOptions); + window.addEventListener("resize", update, eventOptions); + reducedMotion.addEventListener("change", update, eventOptions); + finePointer.addEventListener("change", update, eventOptions); + update(); + + return () => { + if (disposed) return; + disposed = true; + events.abort(); + observer.disconnect(); + update(); + }; +} diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd28b446ccf1..a4fdc966b9d8 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -355,6 +355,7 @@ const screenshot = await getImage({