diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts
index bffd983815..e8fc7fd372 100644
--- a/apps/web/src/branding.ts
+++ b/apps/web/src/branding.ts
@@ -2,3 +2,5 @@ export const APP_BASE_NAME = "T3 Code";
export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha";
export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0";
+export const GITHUB_REPO_URL = "https://github.com/pingdotgg/t3code";
+export const GITHUB_REPO_SLUG = "pingdotgg/t3code";
diff --git a/apps/web/src/components/ChangelogDialog.tsx b/apps/web/src/components/ChangelogDialog.tsx
new file mode 100644
index 0000000000..1dbcd81e2b
--- /dev/null
+++ b/apps/web/src/components/ChangelogDialog.tsx
@@ -0,0 +1,334 @@
+import { useQuery } from "@tanstack/react-query";
+import { ExternalLinkIcon, LoaderCircleIcon } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { APP_VERSION, GITHUB_REPO_URL } from "~/branding";
+import { changelogQueryOptions } from "~/lib/changelogReactQuery";
+import { cn, formatRelativeTime } from "~/lib/utils";
+import { openExternalUrl } from "~/nativeApi";
+import { GitHubIcon } from "./Icons";
+import {
+ Dialog,
+ DialogDescription,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "./ui/dialog";
+
+interface ChangelogDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ highlightVersion?: string | null | undefined;
+}
+
+/**
+ * Parse a single changelog line like:
+ * "fix(web): add pointer cursor by @binbandit in https://github.com/.../pull/1220"
+ * into structured parts for rich rendering.
+ */
+interface ChangelogEntry {
+ description: string;
+ author: string | null;
+ prNumber: string | null;
+ prUrl: string | null;
+}
+
+function parseChangelogLine(line: string): ChangelogEntry {
+ const cleaned = line.replace(/^\*\s*/, "").trim();
+
+ const authorMatch = cleaned.match(/ by @([\w-]+)/);
+ const prMatch = cleaned.match(/ in (https:\/\/github\.com\/[\w-]+\/[\w-]+\/pull\/(\d+))$/);
+
+ let description = cleaned;
+ if (authorMatch) {
+ description = description.slice(0, authorMatch.index).trim();
+ }
+ if (!authorMatch && prMatch) {
+ description = description.slice(0, prMatch.index).trim();
+ }
+
+ return {
+ description,
+ author: authorMatch ? authorMatch[1]! : null,
+ prNumber: prMatch ? prMatch[2]! : null,
+ prUrl: prMatch ? prMatch[1]! : null,
+ };
+}
+
+interface ParsedSection {
+ title: string;
+ entries: ChangelogEntry[];
+}
+
+function parseReleaseBody(body: string): {
+ sections: ParsedSection[];
+ fullChangelogUrl: string | null;
+} {
+ const lines = body.split("\n");
+ const sections: ParsedSection[] = [];
+ let currentSection: ParsedSection | null = null;
+ let fullChangelogUrl: string | null = null;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith("**Full Changelog**:")) {
+ const urlMatch = trimmed.match(/https:\/\/[^\s]+/);
+ if (urlMatch) fullChangelogUrl = urlMatch[0]!;
+ continue;
+ }
+
+ if (trimmed.startsWith("## ")) {
+ currentSection = { title: trimmed.replace(/^##\s*/, ""), entries: [] };
+ sections.push(currentSection);
+ continue;
+ }
+
+ if (trimmed.startsWith("* ") && currentSection) {
+ currentSection.entries.push(parseChangelogLine(trimmed));
+ }
+ }
+
+ return { sections, fullChangelogUrl };
+}
+
+function ChangelogEntryItem({ entry }: { entry: ChangelogEntry }) {
+ return (
+
+
+
+ {entry.description}
+ {entry.author && (
+
+ )}
+ {entry.prNumber && entry.prUrl && (
+
+ )}
+
+
+ );
+}
+
+function ReleaseCard({
+ tagName,
+ publishedAt,
+ body,
+ htmlUrl,
+ isCurrent,
+ isHighlighted,
+ innerRef,
+}: {
+ tagName: string;
+ publishedAt: string | null;
+ body: string | null;
+ htmlUrl: string;
+ isCurrent: boolean;
+ isHighlighted: boolean;
+ innerRef?: ((el: HTMLDivElement | null) => void) | undefined;
+}) {
+ const parsed = body ? parseReleaseBody(body) : null;
+ const hasParsedContent = parsed && parsed.sections.some((s) => s.entries.length > 0);
+
+ return (
+
+
+ {tagName}
+ {isCurrent && (
+
+ current
+
+ )}
+
+ {formatRelativeTime(publishedAt)}
+
+
+
+
+ {hasParsedContent
+ ? parsed.sections.map((section) => (
+
+
+ {section.title}
+
+
+ {section.entries.map((entry) => (
+
+ ))}
+
+
+ ))
+ : body && (
+
+ {body.slice(0, 200)}
+ {body.length > 200 ? "…" : ""}
+
+ )}
+
+ {!body &&
No release notes.
}
+
+ {parsed?.fullChangelogUrl && (
+
+ )}
+
+ );
+}
+
+function isVersionMatch(tagName: string, target: string): boolean {
+ const version = tagName.replace(/^v/, "");
+ return tagName === target || version === target.replace(/^v/, "");
+}
+
+export function ChangelogDialog({ open, onOpenChange, highlightVersion }: ChangelogDialogProps) {
+ const {
+ data: releases,
+ isLoading,
+ error,
+ } = useQuery({ ...changelogQueryOptions(), enabled: open });
+ const [activeTag, setActiveTag] = useState(null);
+ const cardRefs = useRef