From 01a8db0aad27fa18010c552340e1b36168707ae8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 5 Nov 2026 02:35:42 -0600 Subject: [PATCH 1/3] fix(server): refresh divergence after a merged branch ref changes (#31342) --- .../src/vcs/VcsStatusBroadcaster.test.ts | 19 +++++++++++-------- apps/server/src/vcs/VcsStatusBroadcaster.ts | 10 +--------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index be3a286ffc32b..45b130f9a2b4b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -382,13 +382,14 @@ describe("VcsStatusBroadcaster", () => { ); it.effect("refreshes a loaded cwd without reusing a previous branch's PR", () => { - const state = { + const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, + currentRemoteStatus: { ...baseRemoteStatus, aheadCount: 3, behindCount: 5 }, localStatusCalls: 0, remoteStatusCalls: 0, localInvalidationCalls: 0, remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [], }; return Effect.gen(function* () { @@ -400,9 +401,14 @@ describe("VcsStatusBroadcaster", () => { yield* broadcaster.getStatus({ cwd: "/repo" }); assert.equal(state.remoteStatusCalls, 1); - state.currentRemoteStatus = remoteStatusWithPr; + state.backgroundWorkEnabled = false; + state.currentRemoteStatus = { ...remoteStatusWithPr, aheadCount: 7 }; const refreshed = yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.deepStrictEqual(refreshed, remoteStatusWithPr); + assert.deepStrictEqual(refreshed, state.currentRemoteStatus); + const merged = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.equal(merged.aheadCount, 7); + assert.equal(merged.behindCount, 0); + assert.equal(state.remoteStatusRefreshUpstreamValues?.at(-1), false); assert.equal(state.remoteStatusCalls, 2); assert.equal(state.remoteInvalidationCalls, 2); @@ -944,7 +950,7 @@ describe("VcsStatusBroadcaster", () => { }, ); - it.effect("recovers a discarded branch lookup on the next permitted turn-end refresh", () => { + it.effect("recovers a discarded branch lookup on the next turn-end refresh", () => { const state: Parameters[0] = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: { ...remoteStatusWithPr, aheadCount: 3, behindCount: 5 }, @@ -983,9 +989,6 @@ describe("VcsStatusBroadcaster", () => { pr: { ...remoteStatusWithPr.pr!, number: 2979, headRef: "feature/next-status" }, }; state.backgroundWorkEnabled = false; - yield* broadcaster.refreshPullRequestStatus("/repo"); - assert.equal(state.remoteStatusCalls, 1); - state.backgroundWorkEnabled = true; const recovered = yield* broadcaster.refreshPullRequestStatus("/repo"); assert.deepStrictEqual(recovered, state.currentRemoteStatus); const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 327e0bcb1bb41..7fee5379e4a3b 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -540,15 +540,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const cached = yield* getCachedStatus(cwd); if (!cached?.local || cached.remote?.value === null) return null; - const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); - const demandCwds = poller ? [...(yield* Ref.get(poller.demandCwds)).keys()] : [rawCwd]; - const shouldRefresh = (yield* Effect.forEach( - demandCwds, - (demandCwd) => - backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd }), - { concurrency: "unbounded" }, - )).some(Boolean); - if (!shouldRefresh) return null; + // Turn completion must update divergence even when background polling is paused. const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream: false, refreshMissingPullRequest: true, From 8a14762c9093ea11e1e58d65cfe788f458a0d6b5 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:16:54 +0530 Subject: [PATCH 2/3] fix(mobile): one aligned notice for ChatGPT sharing limits (#31277) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../threads/ChatGptUsageLimitNotice.tsx | 69 --------- .../features/threads/ThreadDetailScreen.tsx | 5 - .../threads/UsageLimitRecoveryCard.test.tsx | 96 ++++++++++++- .../threads/UsageLimitRecoveryCard.tsx | 133 ++++++++++++++---- 4 files changed, 200 insertions(+), 103 deletions(-) delete mode 100644 apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx diff --git a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx b/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx deleted file mode 100644 index faebfa5ddf927..0000000000000 --- a/apps/mobile/src/features/threads/ChatGptUsageLimitNotice.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { - CHATGPT_USAGE_LIMIT_CODE, - CHATGPT_USAGE_URL, - CHATGPT_WORKSPACE_ADMIN_REQUEST, - CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, -} from "@t3tools/shared/usageLimits"; -import * as Clipboard from "expo-clipboard"; -import { useState } from "react"; -import { Linking, View } from "react-native"; -import { ProviderIcon } from "../../components/ProviderIcon"; -import { AppText as Text } from "../../components/ui/AppText"; -import { Button } from "../../components/ui/Button"; - -export function ChatGptUsageLimitNotice({ thread }: { thread: EnvironmentThreadShell }) { - const [status, setStatus] = useState<"copied" | "copy-failed" | "open-failed" | null>(null); - const code = thread.runtime?.lastErrorCode; - const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; - if (thread.runtime?.status !== "failed" || (!workspaceLimit && code !== CHATGPT_USAGE_LIMIT_CODE)) - return null; - return ( - - - - ChatGPT usage limit reached - - - {workspaceLimit - ? status === "copied" - ? "Request copied. Send it to your workspace admin." - : "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits." - : "Review your usage settings in ChatGPT."} - - {workspaceLimit ? ( - + ), View: ({ children }: { children: ReactNode }) =>
{children}
, })); +vi.mock("expo-clipboard", () => ({ setStringAsync: vi.fn() })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); vi.mock("../../components/ui/AppText", () => ({ AppText: ({ children }: { children: ReactNode }) => {children}, })); @@ -27,18 +40,97 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; const DAY_MS = 24 * 60 * 60 * 1000; -function failedThread(resetAt: string): EnvironmentThreadShell { +function failedThread( + resetAt: string | null, + lastErrorCode?: string, + lastErrorClass = "usage_limit", +): EnvironmentThreadShell { return { id: "thread-1", updatedAt: new Date(0).toISOString(), snoozedUntil: null, latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() }, limitRecovery: null, - runtime: { status: "failed", lastErrorClass: "usage_limit", usageLimitResetAt: resetAt }, + runtime: { status: "failed", lastErrorClass, lastErrorCode, usageLimitResetAt: resetAt }, } as unknown as EnvironmentThreadShell; } describe("UsageLimitRecoveryCard", () => { + // Older servers report ChatGPT sharing limits as provider errors and may store a reset. + it.each([ + [CHATGPT_USAGE_LIMIT_CODE, "usage_limit", null, ["Manage usage"]], + [CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, "usage_limit", null, ["Copy request for admin"]], + [CHATGPT_USAGE_LIMIT_CODE, "provider_error", null, ["Manage usage"]], + [ + CHATGPT_USAGE_LIMIT_CODE, + "usage_limit", + new Date(DAY_MS).toISOString(), + ["Manage usage", "Resume at reset", "Snooze until reset"], + ], + ])("shows one ChatGPT notice for %s (%s, reset %s)", (code, errorClass, resetAt, actions) => { + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.textContent).toContain("ChatGPT usage limit reached"); + expect(container.textContent).not.toContain("did not report a reset time"); + expect([...container.querySelectorAll("button")].map((button) => button.textContent)).toEqual( + actions, + ); + }); + + it("drops a failed copy's error once a retry copies the request", async () => { + vi.mocked(Clipboard.setStringAsync) + .mockRejectedValueOnce(new Error("denied")) + .mockResolvedValueOnce(true); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + await act(async () => copy()); + expect(container.textContent).toContain("Could not copy the request"); + await act(async () => copy()); + expect(container.textContent).toContain("Request copied"); + expect(container.textContent).not.toContain("Could not copy the request"); + }); + + it("ignores a second copy while the first is still in flight", async () => { + const first = Promise.withResolvers(); + vi.mocked(Clipboard.setStringAsync).mockReset().mockReturnValueOnce(first.promise); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + const copy = () => container.querySelector("button")!.click(); + act(() => copy()); + act(() => copy()); + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(1); + await act(async () => first.resolve(true)); + expect(container.textContent).toContain("Request copied"); + }); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 791dc2ab5d847..27fece0272be6 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -3,9 +3,17 @@ import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" import { AsyncResult } from "effect/reactivity"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId } from "@t3tools/contracts"; +import { + CHATGPT_USAGE_LIMIT_CODE, + CHATGPT_USAGE_URL, + CHATGPT_WORKSPACE_ADMIN_REQUEST, + CHATGPT_WORKSPACE_USAGE_LIMIT_CODE, +} from "@t3tools/shared/usageLimits"; import * as DateTime from "effect/DateTime"; +import * as Clipboard from "expo-clipboard"; import { useState } from "react"; -import { View } from "react-native"; +import { Linking, Pressable, View } from "react-native"; +import { ProviderIcon } from "../../components/ProviderIcon"; import { AppText as Text } from "../../components/ui/AppText"; import { useDeadlinePassed } from "../../lib/clock"; import { threadEnvironment } from "../../state/threads"; @@ -30,12 +38,22 @@ export function UsageLimitRecoveryCard({ const recovery = thread.limitRecovery; const scheduled = recovery?.runId === runId && recovery?.resetAt === resetAt && recovery?.autoResume; + const [copied, setCopied] = useState(false); + const [actionPending, setActionPending] = useState(false); + const code = thread.runtime?.lastErrorCode; + // OpenAI's partner guidelines specify the ChatGPT notice and its action. + const workspaceLimit = code === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; + const chatGptLimit = workspaceLimit || code === CHATGPT_USAGE_LIMIT_CODE; if ( thread.runtime?.status !== "failed" || - thread.runtime.lastErrorClass !== "usage_limit" || + // Older servers report ChatGPT sharing limits as provider errors. + (thread.runtime.lastErrorClass !== "usage_limit" && !chatGptLimit) || !runId ) return null; + const resetText = resetAt + ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` + : null; const snoozed = recovery?.snooze === true && recovery.runId === runId && @@ -68,34 +86,95 @@ export function UsageLimitRecoveryCard({ setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); } } + const action = workspaceLimit + ? { + label: "Copy request for admin", + role: "button" as const, + run: () => { + setError(null); + setCopied(false); + setActionPending(true); + void Clipboard.setStringAsync(CHATGPT_WORKSPACE_ADMIN_REQUEST) + .then( + () => setCopied(true), + () => setError("Could not copy the request. Try again."), + ) + .finally(() => setActionPending(false)); + }, + } + : { + label: "Manage usage", + role: "link" as const, + run: () => { + setError(null); + setActionPending(true); + void Linking.openURL(CHATGPT_USAGE_URL) + .catch(() => setError("Could not open ChatGPT. Go to chatgpt.com → Settings → Usage.")) + .finally(() => setActionPending(false)); + }, + }; return ( - - - {resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` - : "The provider did not report a reset time. Retry manually when your limit is available."} - - {canSchedule ? ( - -