diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5880459d75a1..144879ecdb56 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,4 +1,4 @@ -// Dev container for T3 Code. Mirrors CI (ubuntu-24.04, Node 24, Rust stable) +// Dev container for T3 Code. Mirrors CI (ubuntu-24.04, Node 26, Rust stable) // and the canonical setup in docs/operations/development.md: global `vp`, `vp i`. // Contributor doc: docs/internals/devcontainer.md { @@ -8,7 +8,7 @@ // nodeGypDependencies (default true) brings python3/make/g++, which Linux // needs for node-pty's node-gyp fallback (its prebuilds are mac/win only). "ghcr.io/devcontainers/features/node:2": { - "version": "24" + "version": "26" }, // native/resource-monitor (edition 2024, needs stable >= 1.85). The server // degrades gracefully without the binary, but CI checks cargo fmt + test. diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index afbc603a4748..f323041259f1 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -43,7 +43,7 @@ env: jobs: ios: - name: iPhone 6.9, iPhone 6.5, and iPad 13 + name: iPhone, iPhone Duo, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' runs-on: macos-26 # Capturing every palette multiplies the device matrix by six, and only the @@ -87,6 +87,9 @@ jobs: brew install cameroncooke/axe/axe axe --version + - name: Install foldable simulator controls + run: npm install --global agent-device@0.21.25 + - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/AGENTS.md b/AGENTS.md index df7c035abace..8a842f9cdd4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - Never make a PR unless the developer explicitly asks you to do so. - PRs target this fork (`TrogonStack/t3code`), never `pingdotgg/t3code`, unless the developer asks for the change to go upstream. +- If `origin` is git.cafe, follow [GitCafe mirror](docs/operations/development.md#gitcafe-mirror-optional): use `cafe` for existing PRs, issues, comments and labels, but push and open PRs on GitHub, where CI runs. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. - UI changes need before/after images. Motion or timing needs a short video. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0990c0f52b31..84d0661c5cc0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index ca5b89c9d49f..12d66660ab9c 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -463,14 +463,14 @@ cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 // The sentinel must be a package the CLI bundle leaves external. It used to be // "effect", back when the bundle externalized its runtime deps and the whole // node_modules tree was unpacked. The bundle now inlines its JS dependencies, -// so "effect" no longer exists on disk and only the native packages do — -// resolving node-pty is what actually validates the unpacked tree. +// so "effect" no longer exists on disk and only the native packages do. +// Resolving node-pty is what actually validates the unpacked tree. try { require.resolve("node-pty/package.json"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); const path = require("node:path"); const pkgDir = path.dirname(require.resolve("node-pty/package.json")); // node-pty 1.x is N-API based, so a single Linux pty.node is ABI-stable across -// Node versions — require() succeeding IS the real compatibility test. Look in +// Node versions, so require() succeeding IS the real compatibility test. Look in // the same places node-pty's own loader does. const candidates = [ path.join(pkgDir, "build", "Release", "pty.node"), @@ -699,7 +699,7 @@ const ensureNodePtyImpl = Effect.fn("DesktopWslEnvironment.ensureNodePtyImpl")(f return { ok: false, reason: - 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them — this is a packaging problem with this build. Please report it.', + 'WSL server dependencies could not be loaded (for example "node-pty"). The native packages the server needs are not unpacked where the WSL distro\'s Node can read them. This is a packaging problem with this build. Please report it.', fatal: true, } as const; } diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index aad20935b79c..9b8bcc71886a 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -95,7 +95,7 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site); href={GITHUB_REPOSITORY_URL} target="_blank" rel="noopener noreferrer" - aria-label={`Star T3 Code on GitHub — ${MARKETING_STATS.githubStars} stars`} + aria-label={`Star T3 Code on GitHub, ${MARKETING_STATS.githubStars} stars`} >
diff --git a/apps/marketing/src/pages/legal.astro b/apps/marketing/src/pages/legal.astro index d75413a8da7e..b4ede5224a36 100644 --- a/apps/marketing/src/pages/legal.astro +++ b/apps/marketing/src/pages/legal.astro @@ -24,7 +24,7 @@ const documents = [ --- diff --git a/apps/marketing/src/pages/privacy-policy.astro b/apps/marketing/src/pages/privacy-policy.astro index 52e97084d8c2..ea22d8ad9d35 100644 --- a/apps/marketing/src/pages/privacy-policy.astro +++ b/apps/marketing/src/pages/privacy-policy.astro @@ -17,7 +17,7 @@ const sections = [ ---

- Provisions that by their nature should survive termination—including ownership, disclaimers, - limitations of liability, indemnification, and dispute terms—will survive. + Provisions that by their nature should survive termination (including ownership, disclaimers, + limitations of liability, indemnification, and dispute terms) will survive.

diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index d1d0398e0942..55451537ef5a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -226,7 +226,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.0.0", + version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.1.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9f1a94448508..87c9d3ad50b1 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@clerk/expo": "catalog:", - "@clerk/expo-native-components": "0.2.0", + "@clerk/expo-native-components": "0.4.0", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~58.0.14", diff --git a/apps/mobile/src/components/ui/Toast.tsx b/apps/mobile/src/components/ui/Toast.tsx index ae56348b6d68..3a4e8d5ffcf5 100644 --- a/apps/mobile/src/components/ui/Toast.tsx +++ b/apps/mobile/src/components/ui/Toast.tsx @@ -1,6 +1,6 @@ import { MOBILE_SHAPE } from "../../lib/designTokens"; import { useEffect } from "react"; -import { AccessibilityInfo, Platform, View } from "react-native"; +import { AccessibilityInfo, Platform, View, useWindowDimensions } from "react-native"; import { AppText } from "./AppText"; import { Button } from "./Button"; @@ -20,6 +20,13 @@ export interface ToastProps { /** The owning screen places and dismisses feedback; actions are never implicit. */ export function Toast(props: ToastProps) { + const { fontScale } = useWindowDimensions(); + // A one-line message keeps its action on the same row. Descriptions and + // accessibility text sizes need the full width, so the action moves below. + const inlineAction = !props.description && fontScale < 1.5; + const action = props.action ? ( + - ); - break; - default: - trailing = ( - - ); - break; - } - - return ( -
-
- {group.label} - {status.text} - {!canManageProviders ? ( - - This connection cannot manage provider accounts. - - ) : null} -
-
{trailing}
-
- ); -} - -export function ProviderUpdateEnvironmentRows({ - onInteract, -}: { - readonly onInteract?: () => void; -}) { - const { groups } = useLocalEnvironmentUpdateGroups(); - const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { - reportFailure: false, - }); - const groupByEnvironment = new Map(groups.map((group) => [group.environmentId, group] as const)); - - const [visibleAfterIso] = useState(() => new Date().toISOString()); - - // setPendingEnvironments is an async state update, and PENDING_EXPIRY_MS can - // clear the spinner while a request is still in flight, so a rapid - // double-click (or a click after the expiry fires mid- request) would - // otherwise dispatch a second full round of updates. A ref updates - // synchronously, so we can bail before doing any work. - const inFlightEnvironmentsRef = useRef>(new Set()); - - // Bumped on each dispatch and captured locally, so an attempt that was - // superseded -- e.g. one that already tripped the expiry safety net and was - // retried -- detects it is no longer current and skips every state write when - // it finally resolves, instead of clobbering the newer attempt's - // spinner/result/error or its in-flight guard. - const requestVersionRef = useRef>(new Map()); - - const [pendingEnvironments, setPendingEnvironments] = useState>( - () => new Set(), - ); - const [errorByEnvironment, setErrorByEnvironment] = useState>( - () => new Map(), - ); - const [resultByEnvironment, setResultByEnvironment] = useState< - ReadonlyMap - >(() => new Map()); - - const clearPending = (environmentId: EnvironmentId) => { - setPendingEnvironments((previous) => { - if (!previous.has(environmentId)) { - return previous; - } - const next = new Set(previous); - next.delete(environmentId); - return next; - }); - }; - - const handleUpdate = async (environmentId: EnvironmentId) => { - if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return; - const group = groupByEnvironment.get(environmentId); - if (!group || group.candidates.length === 0) { - return; - } - if (inFlightEnvironmentsRef.current.has(environmentId)) { - return; - } - inFlightEnvironmentsRef.current.add(environmentId); - const requestVersion = (requestVersionRef.current.get(environmentId) ?? 0) + 1; - requestVersionRef.current.set(environmentId, requestVersion); - const isCurrentRequest = () => requestVersionRef.current.get(environmentId) === requestVersion; - onInteract?.(); - const providerCount = group.candidates.length; - const targets = group.candidates.map((candidate) => ({ - driver: candidate.driver, - instanceId: candidate.instanceId, - })); - - setPendingEnvironments((previous) => new Set(previous).add(environmentId)); - setErrorByEnvironment((previous) => { - if (!previous.has(environmentId)) { - return previous; - } - const next = new Map(previous); - next.delete(environmentId); - return next; - }); - setResultByEnvironment((previous) => { - if (!previous.has(environmentId)) { - return previous; - } - const next = new Map(previous); - next.delete(environmentId); - return next; - }); - - const expiry = setTimeout(() => { - if (!isCurrentRequest()) { - return; - } - inFlightEnvironmentsRef.current.delete(environmentId); - clearPending(environmentId); - setErrorByEnvironment((previous) => - new Map(previous).set(environmentId, "Update timed out — try again."), - ); - }, PENDING_EXPIRY_MS); - const settled = await settlePromise(async () => { - const results = await Promise.all( - targets.map(async (target): Promise> => { - try { - const result = await updateProvider({ - environmentId, - input: { provider: target.driver, instanceId: target.instanceId }, - }); - return toProviderUpdateOutcome({ - environmentId, - isPrimary: group.isPrimary, - target, - result, - }); - } catch (error) { - return { - status: "rejected", - reason: error instanceof Error ? error : new Error("Provider update failed."), - }; - } - }), - ); - if (!isCurrentRequest()) { - return; - } - setErrorByEnvironment((previous) => { - if (!previous.has(environmentId)) { - return previous; - } - const next = new Map(previous); - next.delete(environmentId); - return next; - }); - if (results.length === 0) { - setErrorByEnvironment((previous) => - new Map(previous).set( - environmentId, - "This environment isn’t connected — try again once it reconnects.", - ), - ); - return; - } - const rejectedMessage = firstRejectedProviderUpdateMessage(results); - if (rejectedMessage) { - setErrorByEnvironment((previous) => new Map(previous).set(environmentId, rejectedMessage)); - return; - } - const view = getProviderUpdateProgressToastView({ - providers: collectProviderUpdateOutcomeSnapshots(results), - providerCount, - }); - // A stored view never re-polls, so persisting it would pin the row's - // spinner forever once the pending flag expires. Drop it and let the - // live per-environment provider state (pill) plus the pending expiry - // drive the row, so it self-heals to whatever the backend actually did. - if (isTerminalProviderUpdatePhase(view.phase)) { - setResultByEnvironment((previous) => new Map(previous).set(environmentId, view)); - } - }); - clearTimeout(expiry); - if (!isCurrentRequest()) return; - if (settled._tag === "Failure") { - const error = squashAtomCommandFailure(settled); - setErrorByEnvironment((previous) => - new Map(previous).set( - environmentId, - error instanceof Error ? error.message : "Provider update failed.", - ), - ); - } - clearPending(environmentId); - inFlightEnvironmentsRef.current.delete(environmentId); - }; - - const rows = groups - .map((group) => ({ - group, - status: resolveEnvironmentUpdateRowStatus({ - group, - error: errorByEnvironment.get(group.environmentId), - result: resultByEnvironment.get(group.environmentId), - pill: getProviderUpdateSidebarPillView(group.candidates, { - visibleAfterIso, - }), - isPending: pendingEnvironments.has(group.environmentId), - }), - })) - .filter(({ group, status }) => group.candidates.length > 0 || status.kind !== "idle"); - - if (rows.length === 0) { - return null; - } - - return ( -
- {rows.map(({ group, status }) => ( - handleUpdate(group.environmentId)} - /> - ))} -
- ); -} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts deleted file mode 100644 index 4fa45a39d212..000000000000 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.environments.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ConnectionCatalogEntry } from "@t3tools/client-runtime/connection"; - -import { - useEnvironmentProviders, - useEnvironments, - usePrimaryEnvironmentId, -} from "~/state/environments"; -import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; -import { - buildLocalEnvironmentUpdateGroups, - deriveEnvironmentDisplayLabel, - type EnvironmentUpdateConnectionState, - type LocalEnvironmentProvidersInput, - type LocalEnvironmentUpdateGroup, -} from "./ProviderUpdateLaunchNotification.logic"; - -function isLocalConnectionTarget(target: ConnectionCatalogEntry["target"]): boolean { - return target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target); -} - -function normalizeConnectionState(phase: string | undefined): EnvironmentUpdateConnectionState { - switch (phase) { - case "connected": - return "ready"; - case "connecting": - case "reconnecting": - return "connecting"; - case "unsupported": - case "error": - return "error"; - case "offline": - return "disconnected"; - default: - return "connecting"; - } -} - -export function useLocalEnvironmentUpdateGroups(): { - readonly groups: LocalEnvironmentUpdateGroup[]; - readonly isAnySettling: boolean; -} { - const { environments } = useEnvironments(); - const providersByEnvironment = useEnvironmentProviders(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - - const inputs: LocalEnvironmentProvidersInput[] = []; - - for (const environment of environments) { - if (!isLocalConnectionTarget(environment.entry.target)) { - continue; - } - - const isPrimary = environment.environmentId === primaryEnvironmentId; - const serverConfig = environment.serverConfig; - - inputs.push({ - environmentId: environment.environmentId, - label: isPrimary - ? deriveEnvironmentDisplayLabel({ - isWsl: false, - wslDistro: null, - platformOs: serverConfig?.environment.platform.os, - fallbackLabel: environment.label, - }) - : environment.label, - isPrimary, - connectionState: isPrimary ? "ready" : normalizeConnectionState(environment.connection.phase), - providers: providersByEnvironment.get(environment.environmentId) ?? [], - }); - } - - inputs.sort((left, right) => Number(right.isPrimary) - Number(left.isPrimary)); - - return buildLocalEnvironmentUpdateGroups(inputs); -} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 66b8ff5318ab..d6e283fec8db 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -9,25 +9,13 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { - buildLocalEnvironmentUpdateGroups, canOneClickUpdateProviderCandidate, - collectProviderUpdateOutcomeSnapshots, - collectUpdatedProviderSnapshots, - firstRejectedProviderUpdateMessage, - getProviderUpdateProgressToastView, getProviderUpdateRunToastView, getProviderUpdateSidebarPillView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, isProviderSettingsUpdateCandidate, - providerUpdateNotificationKey, - resolveEnvironmentUpdateRowStatus, - type LocalEnvironmentProvidersInput, - type LocalEnvironmentUpdateGroup, - type LocalProviderUpdateOutcome, type ProviderUpdateCandidate, - type ProviderUpdateSidebarPillView, - type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; const checkedAt = "2026-04-23T10:00:00.000Z"; @@ -158,67 +146,6 @@ describe("provider update launch notification logic", () => { expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); }); - it("keeps the same notification key while the published update version is unchanged", () => { - const first = updateCandidate({ - driver: driver("codex"), - version: "1.0.0", - latestVersion: "1.2.0", - }); - const second = updateCandidate({ - driver: driver("codex"), - version: "1.1.0", - latestVersion: "1.2.0", - }); - const nextPublishedVersion = updateCandidate({ - driver: driver("codex"), - version: "1.1.0", - latestVersion: "1.3.0", - }); - - expect(providerUpdateNotificationKey([first])).toBe(providerUpdateNotificationKey([second])); - expect(providerUpdateNotificationKey([nextPublishedVersion])).not.toBe( - providerUpdateNotificationKey([first]), - ); - }); - - it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => { - const targetInstanceId = instanceId("codex_personal"); - const siblingInstanceId = instanceId("codex"); - const updatedPersonal = provider({ - driver: driver("codex"), - instanceId: targetInstanceId, - version: "1.1.0", - latestVersion: "1.1.0", - advisoryStatus: "current", - updateState: { - status: "succeeded", - startedAt: checkedAt, - finishedAt: checkedAt, - message: "Provider updated.", - output: null, - }, - }); - const currentDefaultSibling = provider({ - driver: driver("codex"), - instanceId: siblingInstanceId, - version: "1.1.0", - latestVersion: "1.1.0", - advisoryStatus: "current", - updateState: undefined, - }); - - expect( - collectUpdatedProviderSnapshots({ - results: [ - AsyncResult.success({ - providers: [updatedPersonal, currentDefaultSibling], - }), - ], - providerInstanceIds: new Set([targetInstanceId]), - }), - ).toEqual([updatedPersonal]); - }); - it("shows the active sidebar pill for an update on a non-default instance", () => { const view = getProviderUpdateSidebarPillView([ provider({ driver: driver("claudeAgent") }), @@ -347,140 +274,6 @@ describe("provider update launch notification logic", () => { title: "Claude v1.1.0 update failed", }); }); - - describe("multi-backend update outcomes", () => { - const terminalState = ( - status: "succeeded" | "failed" | "unchanged", - message: string, - ): NonNullable => ({ - status, - startedAt: checkedAt, - finishedAt: checkedAt, - message, - output: null, - }); - - const fulfilledOutcome = ( - isPrimary: boolean, - snapshot: ServerProvider | null, - environment = "env", - ): PromiseSettledResult => ({ - status: "fulfilled", - value: { - environmentId: environment as LocalProviderUpdateOutcome["environmentId"], - isPrimary, - driver: snapshot?.driver ?? driver("codex"), - instanceId: snapshot?.instanceId ?? instanceId("codex"), - provider: snapshot, - }, - }); - - it("surfaces a secondary backend's failed update over the primary's success", () => { - const snapshots = collectProviderUpdateOutcomeSnapshots([ - fulfilledOutcome( - true, - provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "Provider updated."), - }), - ), - fulfilledOutcome( - false, - provider({ - driver: driver("codex"), - updateState: terminalState("failed", "npm: NotFound"), - }), - ), - ]); - - expect(snapshots).toHaveLength(1); - expect(snapshots[0]?.updateState?.status).toBe("failed"); - expect( - getProviderUpdateProgressToastView({ providers: snapshots, providerCount: 1 }), - ).toMatchObject({ phase: "failed" }); - }); - - it("treats a rejected dispatch as not contributing a snapshot", () => { - const primary = provider({ - driver: driver("codex"), - updateState: terminalState("succeeded", "Provider updated."), - }); - const results: PromiseSettledResult[] = [ - fulfilledOutcome(true, primary), - { status: "rejected", reason: new Error("WebSocket closed") }, - ]; - - expect(collectProviderUpdateOutcomeSnapshots(results)).toEqual([primary]); - expect(firstRejectedProviderUpdateMessage(results)).toBe("WebSocket closed"); - }); - }); - - describe("per-environment update grouping", () => { - const environment = ( - input: { - readonly environmentId: string; - readonly providers: ReadonlyArray; - } & Partial>, - ): LocalEnvironmentProvidersInput => ({ - environmentId: input.environmentId as EnvironmentId, - label: input.label ?? input.environmentId, - isPrimary: input.isPrimary ?? false, - connectionState: input.connectionState ?? "ready", - providers: input.providers, - }); - - it("flags settling while a secondary backend is still connecting", () => { - const result = buildLocalEnvironmentUpdateGroups([ - environment({ - environmentId: "env-windows", - isPrimary: true, - providers: [provider({ driver: driver("codex") })], - }), - environment({ environmentId: "env-wsl", connectionState: "connecting", providers: [] }), - ]); - - expect(result.isAnySettling).toBe(true); - expect( - result.groups.find((group) => group.environmentId === ("env-wsl" as EnvironmentId)) - ?.isSettling, - ).toBe(true); - }); - }); - - describe("resolveEnvironmentUpdateRowStatus", () => { - const group: LocalEnvironmentUpdateGroup = { - environmentId: "env-wsl" as EnvironmentId, - label: "WSL", - isPrimary: false, - isSettling: false, - candidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], - providers: [], - }; - const succeededResult: ProviderUpdateToastView = { - phase: "succeeded", - type: "success", - title: "Provider updated", - description: "New sessions will use the updated provider.", - }; - const successPill: ProviderUpdateSidebarPillView = { - key: "succeeded:codex", - tone: "success", - title: "Codex updated", - description: "New sessions will use the updated provider.", - }; - - it("prefers a transport error", () => { - expect( - resolveEnvironmentUpdateRowStatus({ - group, - error: "boom", - result: succeededResult, - pill: successPill, - isPending: true, - }), - ).toMatchObject({ kind: "failed", text: "boom" }); - }); - }); }); it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 8c6576511174..9ff55048541c 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -2,7 +2,6 @@ import { defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, type EnvironmentId, - type ExecutionEnvironmentPlatformOs, type ProviderDriverKind, type ProviderInstanceId, type ServerProvider, @@ -27,27 +26,6 @@ export type ProviderSettingsUpdateCandidate = ServerProvider & { }; }; -export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success"; -export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded"; - -export interface ProviderUpdateToastView { - readonly phase: ProviderUpdateToastPhase; - readonly type: ProviderUpdateToastType; - readonly title: string; - readonly description: string; - readonly dismissAfterVisibleMs?: number; -} - -/** - * A non-terminal ("initial"/"running") snapshot never re-polls itself, so - * storing one pins an update row's spinner indefinitely once its pending flag - * expires; such phases must be dropped in favor of the live per-environment - * provider state instead of being persisted. - */ -export function isTerminalProviderUpdatePhase(phase: ProviderUpdateToastPhase): boolean { - return phase === "succeeded" || phase === "failed" || phase === "unchanged"; -} - export type ProviderUpdateSidebarPillTone = "loading" | "warning" | "error" | "success"; export interface ProviderUpdateSidebarPillView { @@ -100,19 +78,6 @@ function dedupeProvidersByDriver(providers: ReadonlyAr return [...latestProviderByDriver.values()]; } -function dedupeProvidersByInstanceId(providers: ReadonlyArray): T[] { - const latestProviderByInstanceId = new Map(); - - for (const provider of providers) { - const current = latestProviderByInstanceId.get(provider.instanceId); - if (!current || provider.checkedAt.localeCompare(current.checkedAt) >= 0) { - latestProviderByInstanceId.set(provider.instanceId, provider); - } - } - - return [...latestProviderByInstanceId.values()]; -} - function getProviderUpdatedTitle(provider: Pick): string { const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; return provider.version @@ -211,19 +176,6 @@ export function canOneClickUpdateProviderCandidate( ); } -export function providerUpdateNotificationKey( - providers: ReadonlyArray, -): string | null { - const parts = dedupeProvidersByDriver(providers) - .map((provider) => { - const advisory = provider.versionAdvisory; - return [provider.driver, advisory.latestVersion].join(":"); - }) - .toSorted(); - - return parts.length > 0 ? parts.join("|") : null; -} - function formatProviderList(providers: ReadonlyArray>) { const names = providers.map( (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, @@ -234,126 +186,6 @@ function formatProviderList(providers: ReadonlyArray; - readonly oneClickProviders: ReadonlyArray; -}): string { - const fromSettings = input.updateProviders.filter( - (provider) => provider.versionAdvisory.canUpdate, - ); - const manual = input.updateProviders.filter((provider) => !provider.versionAdvisory.canUpdate); - return [ - input.oneClickProviders.length > 0 - ? "Install the update now or review provider settings." - : fromSettings.length > 0 - ? `${formatProviderList(fromSettings)} can be updated from provider settings.` - : null, - manual.length > 0 - ? `Update ${formatProviderList(manual)} with the app or tool that installed ${manual.length === 1 ? "it" : "them"}.` - : null, - ] - .filter((sentence) => sentence !== null) - .join(" "); -} - -export function getProviderUpdateInitialToastView(input: { - readonly updateProviders: ReadonlyArray; - readonly oneClickProviders: ReadonlyArray; -}): ProviderUpdateToastView { - return { - phase: "initial", - type: "warning", - title: getProviderUpdateInitialToastTitle(input.updateProviders), - description: describeInitialUpdates(input), - }; -} - -export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastView): boolean { - return view.phase !== "running"; -} - -function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { - return { - phase: "running", - type: "loading", - title: providerCount === 1 ? "Updating provider" : "Updating providers", - description: "Running provider update command.", - }; -} - -export function getProviderUpdateRejectedToastView( - providerCount: number, - message: string, -): ProviderUpdateToastView { - return { - phase: "failed", - type: "error", - title: providerCount === 1 ? "Provider update failed" : "Provider updates failed", - description: message, - }; -} - -export function getProviderUpdateProgressToastView(input: { - readonly providers: ReadonlyArray; - readonly providerCount: number; -}): ProviderUpdateToastView { - const providers = dedupeProvidersByDriver(input.providers); - const failedProviders = providers.filter((provider) => provider.updateState?.status === "failed"); - if (failedProviders.length > 0) { - return { - phase: "failed", - type: "error", - title: failedProviders.length === 1 ? "Provider update failed" : "Provider updates failed", - description: getFailedProviderUpdateDescription(failedProviders), - }; - } - - const unchangedProviders = providers.filter( - (provider) => provider.updateState?.status === "unchanged", - ); - if (unchangedProviders.length > 0) { - return { - phase: "unchanged", - type: "warning", - title: - unchangedProviders.length === 1 - ? "Provider still needs an update" - : "Providers still need updates", - description: `${formatProviderList(unchangedProviders)} ${ - unchangedProviders.length === 1 ? "still appears" : "still appear" - } outdated. Check provider settings for details.`, - }; - } - - if (providers.some(isProviderUpdateActive)) { - return getProviderUpdateRunningToastView(input.providerCount); - } - - const hasCompleteProviderSnapshots = providers.length >= input.providerCount; - const allProvidersUpdated = - hasCompleteProviderSnapshots && - providers.every( - (provider) => - provider.updateState?.status === "succeeded" || !isProviderUpdateCandidate(provider), - ); - if (allProvidersUpdated) { - return { - phase: "succeeded", - type: "success", - title: input.providerCount === 1 ? "Provider updated" : "Provider updates finished", - description: getProviderUpdatedDescription(input.providerCount), - dismissAfterVisibleMs: PROVIDER_UPDATE_SUCCESS_VISIBLE_MS, - }; - } - - return getProviderUpdateRunningToastView(input.providerCount); -} - export interface ProviderUpdateRun { readonly environmentId: EnvironmentId; readonly machineLabel: string; @@ -390,9 +222,11 @@ export function getProviderUpdateRunOutcome( }; } -export function getProviderUpdateRunToastView( - runs: ReadonlyArray, -): Pick | null { +export function getProviderUpdateRunToastView(runs: ReadonlyArray): { + readonly type: "success" | "error"; + readonly title: string; + readonly description: string; +} | null { const settled = runs .map(getProviderUpdateRunOutcome) .filter((outcome) => outcome.status !== "interrupted"); @@ -421,39 +255,6 @@ export function getProviderUpdateRunToastView( }; } -export function collectUpdatedProviderSnapshots(input: { - readonly results: ReadonlyArray< - AtomCommandResult<{ readonly providers: ReadonlyArray }, unknown> - >; - readonly providerInstanceIds: ReadonlySet; -}): ServerProvider[] { - const matchedProviders: ServerProvider[] = []; - - for (const result of input.results) { - if (result._tag === "Failure") { - continue; - } - for (const provider of result.value.providers) { - if (input.providerInstanceIds.has(provider.instanceId)) { - matchedProviders.push(provider); - } - } - } - - return dedupeProvidersByInstanceId(matchedProviders); -} - -export function firstFailedProviderUpdateMessage( - results: ReadonlyArray>, -): string | null { - const failed = results.find((result) => result._tag === "Failure"); - if (!failed || failed._tag !== "Failure") { - return null; - } - const error = squashAtomCommandFailure(failed); - return error instanceof Error ? error.message : "Provider update failed."; -} - function getUpdateFinishedAt(provider: ServerProvider): string | null { return provider.updateState?.finishedAt ?? null; } @@ -630,17 +431,6 @@ export function getProviderUpdateSidebarPillView( ); } -function getProviderUpdateInitialToastTitle( - providers: ReadonlyArray, -): string { - if (providers.length === 1) { - const provider = providers[0]!; - const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; - return `Update Available: ${providerName} ${formatVersion(provider.versionAdvisory.latestVersion)}`; - } - return `Updates Available: ${providers.length} providers`; -} - function getFailedProviderUpdateDescription(providers: ReadonlyArray): string { if (providers.length === 1) { const provider = providers[0]!; @@ -650,184 +440,3 @@ function getFailedProviderUpdateDescription(providers: ReadonlyArray = { - succeeded: 1, - queued: 2, - running: 2, - unchanged: 3, - failed: 4, -}; - -function providerUpdateOutcomeSeverity(provider: ServerProvider): number { - return PROVIDER_UPDATE_STATUS_SEVERITY[provider.updateState?.status ?? ""] ?? 0; -} - -export function firstRejectedProviderUpdateMessage( - results: ReadonlyArray>, -): string | null { - const rejected = results.find((result) => result.status === "rejected"); - if (!rejected) { - return null; - } - return rejected.reason instanceof Error ? rejected.reason.message : "Provider update failed."; -} - -export function collectProviderUpdateOutcomeSnapshots( - results: ReadonlyArray>, -): ServerProvider[] { - const worstByDriver = new Map(); - for (const result of results) { - if (result.status !== "fulfilled" || result.value.provider === null) { - continue; - } - const provider = result.value.provider; - const current = worstByDriver.get(provider.driver); - if ( - !current || - providerUpdateOutcomeSeverity(provider) > providerUpdateOutcomeSeverity(current) - ) { - worstByDriver.set(provider.driver, provider); - } - } - return [...worstByDriver.values()]; -} - -export function deriveEnvironmentDisplayLabel(input: { - readonly isWsl: boolean; - readonly wslDistro: string | null; - readonly platformOs: ExecutionEnvironmentPlatformOs | undefined; - readonly fallbackLabel: string; -}): string { - if (input.isWsl) { - return input.wslDistro ? `WSL · ${input.wslDistro}` : "WSL"; - } - switch (input.platformOs) { - case "windows": - return "Windows"; - case "darwin": - return "macOS"; - case "linux": - return "Linux"; - default: - return input.fallbackLabel; - } -} - -export type EnvironmentUpdateConnectionState = "connecting" | "ready" | "disconnected" | "error"; - -export interface LocalEnvironmentProvidersInput { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly isPrimary: boolean; - readonly connectionState: EnvironmentUpdateConnectionState; - readonly providers: ReadonlyArray; -} - -export interface LocalEnvironmentUpdateGroup { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly isPrimary: boolean; - readonly isSettling: boolean; - readonly candidates: ProviderUpdateCandidate[]; - readonly providers: ReadonlyArray; -} - -export function buildLocalEnvironmentUpdateGroups( - environments: ReadonlyArray, -): { groups: LocalEnvironmentUpdateGroup[]; isAnySettling: boolean } { - const groups = environments.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - isPrimary: environment.isPrimary, - isSettling: environment.connectionState === "connecting", - candidates: collectProviderUpdateCandidates(environment.providers).filter((candidate) => - canOneClickUpdateProviderCandidate(candidate, environment.providers), - ), - providers: environment.providers, - })); - const isAnySettling = environments.some( - (environment) => environment.connectionState === "connecting", - ); - return { groups, isAnySettling }; -} - -export function environmentGroupsWithUpdates( - groups: ReadonlyArray, -): LocalEnvironmentUpdateGroup[] { - return groups.filter((group) => group.candidates.length > 0); -} - -export function localEnvironmentUpdateNotificationKey( - groups: ReadonlyArray, -): string | null { - const parts = environmentGroupsWithUpdates(groups) - .map((group) => { - const providerParts = group.candidates - .map((candidate) => `${candidate.driver}:${candidate.versionAdvisory.latestVersion}`) - .toSorted() - .join(","); - return `${group.environmentId}=${providerParts}`; - }) - .toSorted(); - return parts.length > 0 ? parts.join("|") : null; -} - -export type ProviderUpdateRowStatusKind = "idle" | "loading" | "success" | "failed" | "unchanged"; - -export interface ProviderUpdateRowStatus { - readonly kind: ProviderUpdateRowStatusKind; - readonly text: string; -} - -function environmentProviderNames(group: LocalEnvironmentUpdateGroup): string { - return group.candidates - .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver) - .join(", "); -} - -export function resolveEnvironmentUpdateRowStatus(input: { - readonly group: LocalEnvironmentUpdateGroup; - readonly error: string | undefined; - readonly result: ProviderUpdateToastView | undefined; - readonly pill: ProviderUpdateSidebarPillView | null; - readonly isPending: boolean; -}): ProviderUpdateRowStatus { - if (input.error) { - return { kind: "failed", text: input.error }; - } - if (input.result) { - switch (input.result.phase) { - case "succeeded": - return { kind: "success", text: "Updated" }; - case "failed": - return { kind: "failed", text: input.result.description }; - case "unchanged": - return { kind: "unchanged", text: input.result.description }; - } - } - if (input.pill) { - switch (input.pill.tone) { - case "success": - return { kind: "success", text: "Updated" }; - case "error": - return { kind: "failed", text: input.pill.description }; - case "warning": - return { kind: "unchanged", text: input.pill.description }; - default: - return { kind: "loading", text: "Updating…" }; - } - } - if (input.result || input.isPending) { - return { kind: "loading", text: "Updating…" }; - } - return { kind: "idle", text: environmentProviderNames(input.group) }; -} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx deleted file mode 100644 index dc46e7f927ac..000000000000 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { DownloadIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - -import { useStableCallback } from "~/hooks/useStableCallback"; -import { useEnvironments } from "~/state/environments"; -import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; -import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; -import { ProviderUpdateEnvironmentRows } from "./ProviderUpdateEnvironmentRows"; -import { useLocalEnvironmentUpdateGroups } from "./ProviderUpdateLaunchNotification.environments"; -import { - collectProviderUpdateCandidates, - environmentGroupsWithUpdates, - getProviderUpdateInitialToastView, - localEnvironmentUpdateNotificationKey, -} from "./ProviderUpdateLaunchNotification.logic"; -import { ProviderUpdatePrimaryNotification } from "./ProviderUpdatePrimaryNotification"; -import { stackedThreadToast, toastManager } from "./ui/toast"; - -function useHasLocalSecondaryEnvironment(): boolean { - const { environments } = useEnvironments(); - return environments.some((environment) => - isDesktopLocalConnectionTarget(environment.entry.target), - ); -} - -export function ProviderUpdateLaunchNotification() { - const hasLocalSecondary = useHasLocalSecondaryEnvironment(); - - return hasLocalSecondary ? ( - - ) : ( - - ); -} - -const seenProviderUpdateNotificationKeys = new Set(); -type ProviderUpdateToastId = ReturnType; - -// While a local backend (e.g. WSL) is still connecting, defer the popover so it -// reflects every environment. Cap the wait so a stuck or failed backend can't -// suppress the primary's updates indefinitely. -const SETTLING_GRACE_MS = 30_000; - -function ProviderUpdateEnvironmentsNotification() { - const navigate = useNavigate(); - const { groups, isAnySettling } = useLocalEnvironmentUpdateGroups(); - const { dismissedNotificationKeys, dismissNotificationKey } = - useDismissedProviderUpdateNotificationKeys(); - - const activeToastRef = useRef<{ - readonly toastId: ProviderUpdateToastId; - readonly key: string; - } | null>(null); - // Whether the user has triggered an update from the current toast. Until they - // do, the prompt is replaced when the available updates change; afterward it - // is kept so in-progress rows are not torn down. - const hasInteractedRef = useRef(false); - - useEffect(() => { - return () => { - if (activeToastRef.current !== null) { - toastManager.close(activeToastRef.current.toastId); - activeToastRef.current = null; - } - }; - }, []); - - const updateGroups = environmentGroupsWithUpdates(groups); - const notificationKey = localEnvironmentUpdateNotificationKey(groups); - const candidateUnion = collectProviderUpdateCandidates( - updateGroups.flatMap((group) => group.candidates), - ); - - const [settleGraceElapsed, setSettleGraceElapsed] = useState(false); - // The grace period restarts with the next settling phase. - if (!isAnySettling && settleGraceElapsed) { - setSettleGraceElapsed(false); - } - useEffect(() => { - if (!isAnySettling) return; - const timer = setTimeout(() => setSettleGraceElapsed(true), SETTLING_GRACE_MS); - return () => clearTimeout(timer); - }, [isAnySettling]); - const isGated = isAnySettling && !settleGraceElapsed; - - const openProviderSettings = () => { - const active = activeToastRef.current; - if (active !== null) { - toastManager.close(active.toastId); - activeToastRef.current = null; - } - void navigate({ to: "/settings/providers" }); - }; - - // The toast closes long after the effect that opened it, so it dismisses the key - // the updates have moved to by then. - const dismissPrompt = useStableCallback(() => { - if (notificationKey) { - dismissNotificationKey(notificationKey); - } - activeToastRef.current = null; - }); - - useEffect(() => { - const canShowPrompt = - notificationKey !== null && - !isGated && - !dismissedNotificationKeys.has(notificationKey) && - !seenProviderUpdateNotificationKeys.has(notificationKey); - - const active = activeToastRef.current; - if ( - active && - active.key !== notificationKey && - !hasInteractedRef.current && - (notificationKey === null || !isGated) - ) { - toastManager.close(active.toastId); - activeToastRef.current = null; - } - - if (!notificationKey || !canShowPrompt || activeToastRef.current !== null) { - return; - } - - seenProviderUpdateNotificationKeys.add(notificationKey); - hasInteractedRef.current = false; - - const toastId = toastManager.add( - stackedThreadToast({ - type: "warning", - title: getProviderUpdateInitialToastView({ - updateProviders: candidateUnion, - oneClickProviders: candidateUnion, - }).title, - description: ( - { - hasInteractedRef.current = true; - }} - /> - ), - timeout: 0, - actionProps: { - children: "Settings", - onClick: openProviderSettings, - }, - actionVariant: "outline", - data: { - hideCopyButton: true, - leadingIcon:
- {saved[key] !== (running?.[key] ?? "") && ( + {(saved[key] !== (running?.[key] ?? "") || override?.variable) && (

Running: {running?.[key] || "Disabled"} + {override?.variable && ( + <> + {" "} + from {override.variable} + + )}

)} diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 74eabfc8a9cc..f0fb75bb26c8 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -250,7 +250,7 @@ export function ThemeImportDialog({ } if (installed.length > 0) onImportedMany(installed, { updated: false }); if (failures.length > 0) { - setError(failures.join(" — ")); + setError(failures.join("; ")); } else if (conflicting.length > 0) { setConflicts(conflicting); } else if (installed.length > 0) { @@ -352,7 +352,7 @@ export function ThemeImportDialog({ } if (resolved.length > 0) onImportedMany(resolved, { updated: mode === "update" }); setConflicts(null); - if (failures.length > 0) setError(failures.join(" — ")); + if (failures.length > 0) setError(failures.join("; ")); else onOpenChange(false); }; diff --git a/apps/web/src/components/settings/ThreadTitleInstructionsSettings.tsx b/apps/web/src/components/settings/ThreadTitleInstructionsSettings.tsx new file mode 100644 index 000000000000..08797e2391d7 --- /dev/null +++ b/apps/web/src/components/settings/ThreadTitleInstructionsSettings.tsx @@ -0,0 +1,60 @@ +import { useRef } from "react"; + +import { Textarea } from "../ui/textarea"; +import { SettingsRow, SettingResetButton } from "./settingsLayout"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { searchableSetting } from "./settingsSearch"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; + +export function ThreadTitleInstructionsSettings() { + const settings = useScopedSettings(); + const { targets } = useSettingsScope(); + const scopeKey = targets.map((target) => `${target.environmentId}:${target.projectId}`).join(","); + const edited = useRef(false); + const updateSettings = useUpdateScopedSettings(); + const mixed = useScopedSettingsMixed(["threadTitleInstructions"]); + + return ( + updateSettings({ threadTitleInstructions: "" })} + /> + ) : null + } + > +
+