diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 2bbd0b897a2..7433fc5dad8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -29,6 +29,7 @@ const clientSettings: ClientSettings = { fontSizePrompt: 14, fontSizeTerminal: 12, fontSmoothing: true, + githubStatusAlertsEnabled: false, glassOpacity: 80, planModeEnabled: false, providerModelPreferences: {}, diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 4b96fb15398..3fe6072776e 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -61,6 +61,9 @@ export function BetaSettingsPanel() { (settings) => settings.sidebarAutoSettleAfterDays, ); const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); + const githubStatusAlertsEnabled = useClientSettings( + (settings) => settings.githubStatusAlertsEnabled, + ); const updateSettings = useUpdateClientSettings(); return ( @@ -126,6 +129,19 @@ export function BetaSettingsPanel() { /> } /> + + updateSettings({ githubStatusAlertsEnabled: Boolean(checked) }) + } + aria-label="Enable GitHub outage alerts" + /> + } + /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 34ed929a479..bd530b56f60 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -197,6 +197,11 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Restore plan mode (legacy)", to: "/settings/beta", }, + { + id: "github-outage-alerts", + title: "GitHub outage alerts", + to: "/settings/beta", + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/components/sidebar/GitHubStatusNotice.tsx b/apps/web/src/components/sidebar/GitHubStatusNotice.tsx new file mode 100644 index 00000000000..f4dd573990e --- /dev/null +++ b/apps/web/src/components/sidebar/GitHubStatusNotice.tsx @@ -0,0 +1,118 @@ +import { ExternalLinkIcon, GithubIcon, TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { + GITHUB_STATUS_PAGE_URL, + GITHUB_STATUS_SUMMARY_URL, + resolveGitHubStatusNotice, + type GitHubStatusNotice as GitHubStatusNoticeView, +} from "../../githubStatus"; +import { useClientSettings } from "../../hooks/useSettings"; +import { readLocalApi } from "../../localApi"; +import { cn } from "../../lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const GITHUB_STATUS_POLL_INTERVAL_MS = 60_000; + +function GitHubStatusTooltip({ notice }: { readonly notice: GitHubStatusNoticeView }) { + return ( +
+
+ + {notice.description} +
+ {notice.affectedComponents.length > 0 ? ( +
+ {notice.affectedComponents.map((component) => ( +
+ {component.name} + {component.statusLabel} +
+ ))} +
+ ) : null} +
+ ); +} + +export function GitHubStatusNotice() { + const enabled = useClientSettings((settings) => settings.githubStatusAlertsEnabled); + const [notice, setNotice] = useState(null); + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + let nextRefresh: number | undefined; + let activeRequest: AbortController | undefined; + + const refresh = async () => { + activeRequest = new AbortController(); + try { + const response = await fetch(GITHUB_STATUS_SUMMARY_URL, { + headers: { Accept: "application/json" }, + signal: activeRequest.signal, + }); + if (!response.ok) return; + const summary: unknown = await response.json(); + if (!cancelled) { + setNotice(resolveGitHubStatusNotice(summary)); + } + } catch { + // A missing status response is not evidence of a GitHub outage. Keep + // the most recent known state and quietly try again on the next tick. + } finally { + activeRequest = undefined; + if (!cancelled) { + nextRefresh = window.setTimeout(refresh, GITHUB_STATUS_POLL_INTERVAL_MS); + } + } + }; + + void refresh(); + return () => { + cancelled = true; + activeRequest?.abort(); + if (nextRefresh !== undefined) window.clearTimeout(nextRefresh); + }; + }, [enabled]); + + const openGitHubStatus = useCallback(() => { + void readLocalApi() + ?.shell.openExternal(GITHUB_STATUS_PAGE_URL) + .catch(() => undefined); + }, []); + + if (!enabled || !notice) return null; + + return ( + + + + {notice.label} + + + } + /> + + + + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index df06c431fd2..a4d8a4b2c74 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -22,6 +22,7 @@ import { } from "../ui/sidebar"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; +import { GitHubStatusNotice } from "./GitHubStatusNotice"; export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, @@ -120,6 +121,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( + diff --git a/apps/web/src/githubStatus.test.ts b/apps/web/src/githubStatus.test.ts new file mode 100644 index 00000000000..bc0b039a59f --- /dev/null +++ b/apps/web/src/githubStatus.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveGitHubStatusNotice } from "./githubStatus"; + +function statusSummary(input?: { + readonly indicator?: string; + readonly description?: string; + readonly components?: ReadonlyArray<{ + readonly name: string; + readonly status: string; + readonly showcase?: boolean; + }>; +}) { + return { + status: { + indicator: input?.indicator ?? "none", + description: input?.description ?? "All Systems Operational", + }, + components: input?.components ?? [ + { name: "Git Operations", status: "operational", showcase: true }, + { name: "Actions", status: "operational", showcase: true }, + ], + }; +} + +describe("GitHub status notice", () => { + it("stays hidden while GitHub reports all systems operational", () => { + expect(resolveGitHubStatusNotice(statusSummary())).toBeNull(); + }); + + it("lists affected public services and derives the strongest tone", () => { + expect( + resolveGitHubStatusNotice( + statusSummary({ + indicator: "major", + description: "Partial System Outage", + components: [ + { name: "Git Operations", status: "operational", showcase: true }, + { name: "Actions", status: "major_outage", showcase: true }, + { name: "Pages", status: "degraded_performance", showcase: true }, + { name: "Internal rollup", status: "major_outage", showcase: false }, + ], + }), + ), + ).toEqual({ + affectedComponents: [ + { name: "Actions", status: "major_outage", statusLabel: "Major outage" }, + { + name: "Pages", + status: "degraded_performance", + statusLabel: "Degraded performance", + }, + ], + description: "Partial System Outage", + label: "GitHub Outage: Actions, Pages", + tone: "error", + }); + }); + + it("uses a compact count when several services are affected", () => { + const notice = resolveGitHubStatusNotice( + statusSummary({ + indicator: "minor", + description: "Minor Service Outage", + components: [ + { name: "API Requests", status: "degraded_performance" }, + { name: "Issues", status: "degraded_performance" }, + { name: "Pull Requests", status: "under_maintenance" }, + ], + }), + ); + + expect(notice?.label).toBe("GitHub Outage: 3 services affected"); + expect(notice?.tone).toBe("warning"); + }); + + it("falls back to the global disruption when no component is named", () => { + expect( + resolveGitHubStatusNotice( + statusSummary({ + indicator: "minor", + description: "Minor Service Outage", + components: [], + }), + ), + ).toEqual({ + affectedComponents: [], + description: "Minor Service Outage", + label: "GitHub Outage: service disruption", + tone: "warning", + }); + }); + + it("ignores malformed responses", () => { + expect(resolveGitHubStatusNotice({ status: "down" })).toBeNull(); + }); +}); diff --git a/apps/web/src/githubStatus.ts b/apps/web/src/githubStatus.ts new file mode 100644 index 00000000000..7843a0f9a4c --- /dev/null +++ b/apps/web/src/githubStatus.ts @@ -0,0 +1,94 @@ +import * as Schema from "effect/Schema"; + +export const GITHUB_STATUS_PAGE_URL = "https://www.githubstatus.com"; +export const GITHUB_STATUS_SUMMARY_URL = `${GITHUB_STATUS_PAGE_URL}/api/v2/summary.json`; + +const GitHubStatusSummarySchema = Schema.Struct({ + status: Schema.Struct({ + description: Schema.String, + indicator: Schema.String, + }), + components: Schema.Array( + Schema.Struct({ + name: Schema.String, + status: Schema.String, + showcase: Schema.optional(Schema.Boolean), + }), + ), +}); + +const decodeGitHubStatusSummary = Schema.decodeUnknownOption(GitHubStatusSummarySchema); + +export type GitHubStatusNoticeTone = "warning" | "error"; + +export interface GitHubStatusComponentIssue { + readonly name: string; + readonly status: string; + readonly statusLabel: string; +} + +export interface GitHubStatusNotice { + readonly affectedComponents: ReadonlyArray; + readonly description: string; + readonly label: string; + readonly tone: GitHubStatusNoticeTone; +} + +function componentStatusLabel(status: string): string { + switch (status) { + case "degraded_performance": + return "Degraded performance"; + case "partial_outage": + return "Partial outage"; + case "major_outage": + return "Major outage"; + case "under_maintenance": + return "Under maintenance"; + default: + return status.replaceAll("_", " "); + } +} + +function isErrorStatus(status: string): boolean { + return status === "partial_outage" || status === "major_outage"; +} + +function affectedServicesLabel(components: ReadonlyArray): string { + if (components.length === 0) return "service disruption"; + if (components.length <= 2) return components.map((component) => component.name).join(", "); + return `${components.length} services affected`; +} + +export function resolveGitHubStatusNotice(input: unknown): GitHubStatusNotice | null { + const decoded = decodeGitHubStatusSummary(input); + if (decoded._tag === "None") return null; + + const summary = decoded.value; + const affectedComponents = summary.components + .filter((component) => component.showcase !== false && component.status !== "operational") + .map( + (component): GitHubStatusComponentIssue => ({ + name: component.name, + status: component.status, + statusLabel: componentStatusLabel(component.status), + }), + ); + + if (summary.status.indicator === "none" && affectedComponents.length === 0) { + return null; + } + + const tone = + summary.status.indicator === "major" || + summary.status.indicator === "critical" || + affectedComponents.some((component) => isErrorStatus(component.status)) + ? "error" + : "warning"; + + return { + affectedComponents, + description: summary.status.description, + label: `GitHub Outage: ${affectedServicesLabel(affectedComponents)}`, + tone, + }; +} diff --git a/docs/user/images/github-status-before-after.png b/docs/user/images/github-status-before-after.png new file mode 100644 index 00000000000..b4ea3fa3c3a Binary files /dev/null and b/docs/user/images/github-status-before-after.png differ diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 6d81d2b33ab..6f252d3a6b3 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -127,9 +127,12 @@ Control settings**. **Common issues:** - **Provider shows "Not authenticated"** – Run the login command for that provider (e.g., `gh auth login`) in a terminal on the server, then rescan in Settings +- **GitHub operations are unexpectedly failing** – Enable **GitHub outage alerts** in **Settings → Beta**. When GitHub reports a service disruption, T3 Code shows the affected services above **Settings** in the web and desktop sidebar. Select the notice to open the official GitHub Status page. - **Bitbucket not connecting** – Double-check your environment variables are set in the correct shell profile and the server was restarted - **Can't push to a remote** – Verify your Git remote URL matches the provider you've authenticated with (SSH vs HTTPS remotes may need different credentials) +![The sidebar stays quiet while GitHub is healthy and shows affected services during an outage](./images/github-status-before-after.png) + **Need more help?** Check your provider's CLI documentation: - [GitHub CLI](https://cli.github.com/) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1faef89e47d..be3ebe0c583 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -126,6 +126,21 @@ describe("ClientSettings completion sound", () => { }); }); +describe("ClientSettings GitHub status alerts", () => { + it("defaults the beta off", () => { + expect(decodeClientSettings({}).githubStatusAlertsEnabled).toBe(false); + }); + + it("accepts the setting in stored settings and patches", () => { + expect( + decodeClientSettings({ githubStatusAlertsEnabled: true }).githubStatusAlertsEnabled, + ).toBe(true); + expect(decodeClientSettingsPatch({ githubStatusAlertsEnabled: true })).toEqual({ + githubStatusAlertsEnabled: true, + }); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults text generation to Luna at low reasoning effort", () => { expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fe994a2e75b..bbefdd18ee5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -149,6 +149,7 @@ export const ClientSettingsSchema = Schema.Struct({ // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + githubStatusAlertsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -772,6 +773,7 @@ export const ClientSettingsPatch = Schema.Struct({ fontFamilySans: Schema.optionalKey(FontFamilyPreference), fontFamilyTerminal: Schema.optionalKey(FontFamilyPreference), fontSmoothing: Schema.optionalKey(Schema.Boolean), + githubStatusAlertsEnabled: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({