diff --git a/apps/desktop/src/design/preview.css b/apps/desktop/src/design/preview.css
index 815ff991..666c6c99 100644
--- a/apps/desktop/src/design/preview.css
+++ b/apps/desktop/src/design/preview.css
@@ -122,6 +122,23 @@
background: var(--ds-color-fill-hover);
}
+.ds-preview-sidebar > .ds-lab-link {
+ display: flex;
+ min-height: var(--ds-control-normal);
+ align-items: center;
+ padding: 0 var(--ds-space-module-inset);
+ color: var(--ds-color-primary);
+ background: var(--ds-color-fill-quiet);
+ border-radius: var(--ds-radius-control);
+ font-size: var(--ds-type-metadata-size);
+ font-weight: var(--ds-font-weight-medium);
+ text-decoration: none;
+}
+
+.ds-preview-sidebar > .ds-lab-link:hover {
+ background: var(--ds-color-fill-hover);
+}
+
.ds-version-card {
display: flex;
align-items: flex-start;
diff --git a/apps/desktop/src/design/ui-lab/UiLab.tsx b/apps/desktop/src/design/ui-lab/UiLab.tsx
new file mode 100644
index 00000000..a2070fb2
--- /dev/null
+++ b/apps/desktop/src/design/ui-lab/UiLab.tsx
@@ -0,0 +1,364 @@
+import { useState, type ComponentType, type ReactNode } from "react";
+import {
+ ArrowLeft,
+ Code2,
+ GitPullRequest,
+ Globe,
+ MessageSquare,
+ Monitor,
+ Moon,
+ Palette,
+ PanelRight,
+ Sparkles,
+ Sun,
+} from "@/components/ui/icons";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+import { DesignSystemPreview } from "../DesignSystemPreview";
+import { Dock, type DockTab } from "../../dock/Dock";
+import { GitHubPullRequestPanel } from "../../git/GitHubPullRequestPanel";
+import { PullRequestsPage } from "../../github/PullRequestsPage";
+import { useLanguage } from "../../i18n";
+import { useTheme } from "../../theme";
+import {
+ loadPullRequest,
+ loadPullRequests,
+ pullRequestPanelApi,
+ pullRequestTasks,
+} from "./fixtures";
+import "./ui-lab.css";
+
+type UiLabRoute = "home" | "design-system" | "pull-requests" | "pr-dock";
+type UiLabCard = {
+ route: UiLabRoute;
+ icon: ComponentType<{ className?: string }>;
+ title: string;
+ description: string;
+ tags: readonly string[];
+ fixture?: boolean;
+};
+
+const catalogCards: readonly UiLabCard[] = [
+ {
+ route: "design-system",
+ icon: Palette,
+ title: "Design system",
+ description: "Tokens, type, spacing, controls, business primitives, states, and accessibility.",
+ tags: ["Foundations", "Components", "States"],
+ },
+ {
+ route: "pull-requests",
+ icon: GitPullRequest,
+ title: "PR workspace",
+ description: "Production list, detail, changes, checks, task link, and inspector in one stable state.",
+ tags: ["Master detail", "Inspector", "Responsive"],
+ fixture: true,
+ },
+ {
+ route: "pr-dock",
+ icon: PanelRight,
+ title: "Conversation PR Dock",
+ description: "The production PR review panel beside a restrained conversation shell.",
+ tags: ["Dock", "Review", "Diff"],
+ fixture: true,
+ },
+];
+
+const previewCards = [
+ {
+ href: "?rich-transcript=1",
+ icon: MessageSquare,
+ title: "Rich transcript",
+ description: "Conversation content, tool calls, tables, and long-form rendering.",
+ },
+ {
+ href: "?pet-preview=1",
+ icon: Sparkles,
+ title: "Pet animation",
+ description: "Desktop pet sprites, states, direction, and motion preview.",
+ },
+] as const;
+
+function routeFromLocation(): UiLabRoute {
+ const value = new URLSearchParams(window.location.search).get("ui-lab");
+ return value === "design-system" || value === "pull-requests" || value === "pr-dock"
+ ? value
+ : "home";
+}
+
+function uiLabHref(
+ route: UiLabRoute,
+ theme: "system" | "light" | "dark",
+ locale: "en" | "zh-CN",
+): string {
+ const params = new URLSearchParams({ "ui-lab": route, theme, lang: locale === "zh-CN" ? "zh" : "en" });
+ return `?${params.toString()}`;
+}
+
+function CatalogCard({ card }: { card: UiLabCard }) {
+ const { preference } = useTheme();
+ const { locale } = useLanguage();
+ const Icon = card.icon;
+ return (
+
+
+
+ {card.title}
+ →
+
+ {card.description}
+
+
+ {card.tags.map((tag) => {tag})}
+
+ {card.fixture ? Fixture : null}
+
+
+ );
+}
+
+function PreviewCard({ card }: { card: (typeof previewCards)[number] }) {
+ const Icon = card.icon;
+ return (
+
+
+
+ {card.title}
+ {card.description}
+
+ ↗
+
+ );
+}
+
+function ThemeLinks({ route }: { route: UiLabRoute }) {
+ const { preference } = useTheme();
+ const { locale } = useLanguage();
+ const items = [
+ { id: "system", label: "System", icon: Monitor },
+ { id: "light", label: "Light", icon: Sun },
+ { id: "dark", label: "Dark", icon: Moon },
+ ] as const;
+ return (
+
+ {items.map(({ id, label, icon: Icon }) => (
+
+
+
+ ))}
+
+ );
+}
+
+function LocaleLinks({ route }: { route: UiLabRoute }) {
+ const { preference } = useTheme();
+ const { locale } = useLanguage();
+ return (
+
+ );
+}
+
+function ScenarioToolbar({
+ route,
+ title,
+}: {
+ route: UiLabRoute;
+ title: string;
+}) {
+ const { preference } = useTheme();
+ const { locale } = useLanguage();
+ return (
+
+
+
+ UI Lab
+
+ {title}
+
+ Deterministic fixture
+ Dev only
+
+
+
+ );
+}
+
+function ScenarioShell({
+ children,
+ route,
+ title,
+}: {
+ children: ReactNode;
+ route: UiLabRoute;
+ title: string;
+}) {
+ return (
+
+ );
+}
+
+function PullRequestsScenario() {
+ return (
+
+ {}}
+ onOpenTask={() => {}}
+ tasks={pullRequestTasks}
+ />
+
+ );
+}
+
+function ConversationFixture() {
+ return (
+
+
+
+
+
+ YOU
+ Can you review the usage widget changes and make sure the narrow layout still works?
+
+
+ C2
+ The implementation is ready for review. I verified the responsive shell and the three desktop checks pass.
+ 3 checks passed · 30 files changed
+
+
+
+
+ Ask a follow-up…
+ ⌘ ↵
+
+
+ );
+}
+
+function PullRequestDockScenario() {
+ const [tab, setTab] = useState
("pull-request");
+ return (
+
+
+
+
+
+
+
+
+ ),
+ }}
+ onClose={() => {}}
+ onTab={setTab}
+ onWidth={() => {}}
+ open
+ tab={tab}
+ width={420}
+ />
+
+
+ );
+}
+
+function Catalog() {
+ const { preference } = useTheme();
+ const { locale } = useLanguage();
+ return (
+
+
+
+
+
+
+
+
+
01
Production scenarios
+
These pages render the same components used in C2.
+
+
+ {catalogCards.map((card) => )}
+
+
+
+
+
+
02
Focused previews
+
Existing single-purpose development surfaces remain directly accessible.
+
+
+ {previewCards.map((card) =>
)}
+
+
+
+
+ );
+}
+
+export function UiLab({ route: routeOverride }: { route?: UiLabRoute }) {
+ const route = routeOverride ?? routeFromLocation();
+ const { preference } = useTheme();
+ const { locale } = useLanguage();
+ if (route === "design-system") {
+ return (
+
+ );
+ }
+ if (route === "pull-requests") return ;
+ if (route === "pr-dock") return ;
+ return ;
+}
diff --git a/apps/desktop/src/design/ui-lab/fixtures.ts b/apps/desktop/src/design/ui-lab/fixtures.ts
new file mode 100644
index 00000000..2d567726
--- /dev/null
+++ b/apps/desktop/src/design/ui-lab/fixtures.ts
@@ -0,0 +1,186 @@
+import type {
+ GitHubPullRequest,
+ GitHubPullRequestDetail,
+ GitHubPullRequestSummary,
+ SourceControlInfo,
+} from "../../bridge";
+import type { GitHubPullRequestPanelApi } from "../../git/GitHubPullRequestPanel";
+import { githubPullRequestReference } from "../../github/pullRequests";
+import {
+ associateTaskPullRequest,
+ createBoardTask,
+ type BoardTask,
+} from "../../taskboard/taskBoard";
+
+const stableTimestamp = "2099-01-01T10:00:00Z";
+
+export const pullRequestSummary: GitHubPullRequestSummary = {
+ id: "https://github.com/acme/code-two/pull/279",
+ number: 279,
+ title: "feat(dashboard): add sidebar usage widget",
+ url: "https://github.com/acme/code-two/pull/279",
+ repository: { name: "code-two", nameWithOwner: "acme/code-two" },
+ author: { login: "stylesh" },
+ isDraft: false,
+ updatedAt: stableTimestamp,
+ createdAt: stableTimestamp,
+ labels: [
+ { name: "dashboard", color: "8250df" },
+ { name: "ready", color: "2da44e" },
+ ],
+ commentsCount: 5,
+ authored: true,
+ reviewRequested: false,
+ reviewed: true,
+};
+
+export const reviewRequestSummary: GitHubPullRequestSummary = {
+ ...pullRequestSummary,
+ id: "https://github.com/acme/code-two/pull/276",
+ number: 276,
+ title: "fix(session): preserve generated title fallback",
+ url: "https://github.com/acme/code-two/pull/276",
+ author: { login: "octocat" },
+ labels: [{ name: "session", color: "0969da" }],
+ commentsCount: 2,
+ authored: false,
+ reviewRequested: true,
+ reviewed: false,
+};
+
+export const pullRequestDetail: GitHubPullRequestDetail = {
+ ...pullRequestSummary,
+ body: [
+ "## Summary",
+ "",
+ "- add a persistent sidebar usage card with compact AI and CI rings",
+ "- keep wallet balance private by default and reveal it on demand",
+ "- avoid hidden polling and report unpriced activity as pending",
+ "",
+ "## Verification",
+ "",
+ "- `bun test`",
+ "- `bun run build`",
+ "- live browser verification at desktop and narrow widths",
+ ].join("\n"),
+ additions: 769,
+ deletions: 144,
+ changedFiles: 30,
+ baseRefName: "main",
+ headRefName: "feat/sidebar-usage-widget",
+ state: "OPEN",
+ mergeStateStatus: "CLEAN",
+ mergeable: "MERGEABLE",
+ reviewDecision: "APPROVED",
+ reviewers: [{ login: "octocat", state: "APPROVED" }],
+ checks: [
+ { name: "Desktop tests", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null },
+ { name: "Type check", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null },
+ { name: "Documentation", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null },
+ ],
+ files: [
+ { path: "apps/desktop/src/App.tsx", additions: 210, deletions: 41, changeType: "MODIFIED" },
+ { path: "apps/desktop/src/github/PullRequestsPage.tsx", additions: 418, deletions: 62, changeType: "ADDED" },
+ { path: "apps/desktop/tests/githubPullRequestsRendered.test.tsx", additions: 141, deletions: 0, changeType: "ADDED" },
+ ],
+};
+
+export const reviewRequestDetail: GitHubPullRequestDetail = {
+ ...pullRequestDetail,
+ ...reviewRequestSummary,
+ body: "## Summary\n\n- retain a useful title when the provider sends an empty generated value",
+ additions: 84,
+ deletions: 17,
+ changedFiles: 4,
+ headRefName: "fix/session-title-fallback",
+ reviewDecision: "REVIEW_REQUIRED",
+ reviewers: [],
+};
+
+const task = createBoardTask(
+ {
+ title: "Ship sidebar usage widget",
+ description: "Review the implementation and ship the dashboard usage card.",
+ status: "in_review",
+ priority: "high",
+ labels: ["dashboard", "review"],
+ },
+ { id: "ui-lab-pr-task", now: 1 },
+);
+
+export const pullRequestTasks: readonly BoardTask[] = associateTaskPullRequest(
+ [task],
+ task.id,
+ githubPullRequestReference(pullRequestSummary),
+ 1,
+) ?? [task];
+
+export const loadPullRequests = async (): Promise => [
+ pullRequestSummary,
+ reviewRequestSummary,
+];
+
+export const loadPullRequest = async (
+ summary: GitHubPullRequestSummary,
+): Promise => summary.id === reviewRequestSummary.id
+ ? reviewRequestDetail
+ : pullRequestDetail;
+
+const sourceControl: SourceControlInfo = {
+ remote_name: "origin",
+ provider: "github",
+ provider_name: "GitHub",
+ host: "github.com",
+ web_url: "https://github.com/acme/code-two",
+ change_request_label: "PR",
+ create_change_request_supported: true,
+ required_cli: "gh",
+ required_cli_available: true,
+};
+
+const dockPullRequest: GitHubPullRequest = {
+ number: pullRequestDetail.number,
+ title: pullRequestDetail.title,
+ url: pullRequestDetail.url,
+ state: pullRequestDetail.state,
+ is_draft: pullRequestDetail.isDraft,
+ head_ref: pullRequestDetail.headRefName,
+ base_ref: pullRequestDetail.baseRefName,
+ additions: pullRequestDetail.additions,
+ deletions: pullRequestDetail.deletions,
+ changed_files: pullRequestDetail.changedFiles,
+ body: pullRequestDetail.body,
+ review_decision: pullRequestDetail.reviewDecision,
+ mergeable: pullRequestDetail.mergeable,
+ merge_state_status: pullRequestDetail.mergeStateStatus,
+ author: pullRequestDetail.author.login,
+ comments_count: pullRequestDetail.commentsCount,
+ reviews_count: pullRequestDetail.reviewers.length,
+ checks: pullRequestDetail.checks.map((check) => ({
+ name: check.name,
+ status: check.status,
+ conclusion: check.conclusion,
+ details_url: check.detailsUrl,
+ workflow_name: "Desktop",
+ })),
+ created_at: pullRequestDetail.createdAt,
+ updated_at: pullRequestDetail.updatedAt,
+};
+
+export const pullRequestPanelApi: GitHubPullRequestPanelApi = {
+ sourceControl: async () => sourceControl,
+ currentPullRequest: async () => dockPullRequest,
+ pullRequestDiff: async () => ({
+ text: [
+ "diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx",
+ "@@ -412,6 +412,7 @@ function App() {",
+ "- const showUsage = false;",
+ "+ const showUsage = account !== null;",
+ " return ;",
+ ].join("\n"),
+ truncated: false,
+ }),
+ review: async () => {},
+ merge: async () => {},
+ open: async () => {},
+};
diff --git a/apps/desktop/src/design/ui-lab/ui-lab.css b/apps/desktop/src/design/ui-lab/ui-lab.css
new file mode 100644
index 00000000..3b8b9a47
--- /dev/null
+++ b/apps/desktop/src/design/ui-lab/ui-lab.css
@@ -0,0 +1,638 @@
+.ui-lab-root,
+.ui-lab-root *,
+.ui-lab-scenario,
+.ui-lab-scenario * {
+ box-sizing: border-box;
+}
+
+.ui-lab-root {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ grid-template-columns: 196px minmax(0, 1fr);
+ overflow: hidden;
+ color: var(--foreground);
+ background: var(--background);
+}
+
+.ui-lab-sidebar {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: var(--ds-space-section);
+ padding: var(--ds-space-section) var(--ds-space-surface-inset);
+ background: var(--sidebar);
+ box-shadow: var(--ds-elevation-surface);
+}
+
+.ui-lab-brand {
+ display: flex;
+ align-items: center;
+ gap: var(--ds-space-module-inset);
+ padding-inline: var(--ds-space-inline);
+}
+
+.ui-lab-brand > span {
+ display: grid;
+ width: var(--ds-control-field);
+ height: var(--ds-control-field);
+ flex: 0 0 auto;
+ place-items: center;
+ color: var(--primary-foreground);
+ background: var(--primary);
+ border-radius: var(--ds-radius-control);
+ font-size: var(--ds-type-caption-size);
+ font-weight: var(--ds-font-weight-medium);
+ letter-spacing: 0.04em;
+}
+
+.ui-lab-brand > div {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+}
+
+.ui-lab-brand strong {
+ font-size: var(--ds-type-dialog-size);
+ font-weight: var(--ds-font-weight-medium);
+}
+
+.ui-lab-brand small,
+.ui-lab-sidebar-note,
+.ui-lab-section-heading p,
+.ui-lab-header p,
+.ui-lab-card-description,
+.ui-lab-preview-card small {
+ color: var(--muted-foreground);
+ font-size: var(--ds-type-metadata-size);
+ line-height: var(--ds-type-metadata-leading);
+}
+
+.ui-lab-sidebar nav {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ds-space-optical);
+}
+
+.ui-lab-sidebar nav a {
+ display: flex;
+ min-height: var(--ds-control-normal);
+ align-items: center;
+ padding-inline: var(--ds-space-module-inset);
+ color: var(--muted-foreground);
+ border-radius: var(--ds-radius-control);
+ text-decoration: none;
+}
+
+.ui-lab-sidebar nav a:hover,
+.ui-lab-sidebar nav a[aria-current="page"] {
+ color: var(--foreground);
+ background: var(--ds-color-fill-hover);
+}
+
+.ui-lab-sidebar-note {
+ display: grid;
+ gap: var(--ds-space-module-inset);
+ margin-top: auto;
+ padding: var(--ds-space-module-inset);
+ background: var(--ds-color-fill-quiet);
+ border-radius: var(--ds-radius-module);
+}
+
+.ui-lab-sidebar-note p {
+ margin: 0;
+}
+
+.ui-lab-main {
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ padding: clamp(var(--ds-space-page), 4vw, 48px);
+}
+
+.ui-lab-main > * {
+ width: min(100%, 1160px);
+ margin-inline: auto;
+}
+
+.ui-lab-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--ds-space-page);
+ padding-block: var(--ds-space-page) 56px;
+}
+
+.ui-lab-eyebrow {
+ color: var(--primary);
+ font-size: var(--ds-type-caption-size);
+ font-weight: var(--ds-font-weight-medium);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.ui-lab-header h1 {
+ margin: var(--ds-space-control-group) 0 var(--ds-space-module-inset);
+ font-size: 34px;
+ font-weight: var(--ds-font-weight-medium);
+ letter-spacing: -0.035em;
+ line-height: 1.05;
+}
+
+.ui-lab-header p {
+ max-width: 620px;
+ margin: 0;
+ font-size: var(--ds-type-body-size);
+ line-height: var(--ds-type-prose-leading);
+}
+
+.ui-lab-header-controls,
+.ui-lab-scenario-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--ds-space-module-inset);
+}
+
+.ui-lab-control-group {
+ display: flex;
+ min-height: var(--ds-control-normal);
+ align-items: center;
+ gap: var(--ds-space-optical);
+ padding: var(--ds-space-optical);
+ background: var(--ds-color-fill-quiet);
+ border-radius: var(--ds-radius-control);
+}
+
+.ui-lab-control-group a {
+ display: grid;
+ min-width: 28px;
+ height: 28px;
+ place-items: center;
+ padding-inline: var(--ds-space-control-group);
+ color: var(--muted-foreground);
+ border-radius: var(--ds-radius-micro);
+ text-decoration: none;
+}
+
+.ui-lab-control-group a:hover,
+.ui-lab-control-group a[aria-current="true"] {
+ color: var(--foreground);
+ background: var(--ds-color-surface);
+ box-shadow: var(--ds-elevation-surface);
+}
+
+.ui-lab-locale-links {
+ padding-inline-start: var(--ds-space-module-inset);
+}
+
+.ui-lab-locale-links a {
+ font-size: var(--ds-type-caption-size);
+ font-weight: var(--ds-font-weight-medium);
+}
+
+.ui-lab-section {
+ scroll-margin-top: var(--ds-space-page);
+}
+
+.ui-lab-section + .ui-lab-section {
+ margin-top: 52px;
+ padding-bottom: 52px;
+}
+
+.ui-lab-section-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--ds-space-page);
+ margin-bottom: var(--ds-space-section);
+}
+
+.ui-lab-section-heading > div {
+ display: flex;
+ align-items: baseline;
+ gap: var(--ds-space-module-inset);
+}
+
+.ui-lab-section-heading span {
+ color: var(--muted-foreground);
+ font-family: var(--ds-font-mono);
+ font-size: var(--ds-type-caption-size);
+}
+
+.ui-lab-section-heading h2 {
+ margin: 0;
+ font-size: var(--ds-type-section-size);
+ font-weight: var(--ds-font-weight-medium);
+}
+
+.ui-lab-section-heading p {
+ margin: 0;
+}
+
+.ui-lab-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: var(--ds-space-section);
+}
+
+.ui-lab-card {
+ display: flex;
+ min-height: 230px;
+ flex-direction: column;
+ gap: var(--ds-space-surface-inset);
+ padding: var(--ds-space-section);
+ color: var(--foreground);
+ background: var(--ds-color-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--ds-radius-modal);
+ box-shadow: var(--ds-elevation-surface);
+ text-decoration: none;
+ transition: border-color var(--ds-motion-feedback) var(--ds-ease-enter),
+ box-shadow var(--ds-motion-feedback) var(--ds-ease-enter),
+ transform var(--ds-motion-feedback) var(--ds-ease-enter);
+}
+
+.ui-lab-card:hover {
+ border-color: color-mix(in oklch, var(--primary) 38%, var(--border));
+ box-shadow: var(--ds-elevation-raised);
+ transform: translateY(-2px);
+}
+
+.ui-lab-card-icon {
+ display: grid;
+ width: var(--ds-control-field);
+ height: var(--ds-control-field);
+ place-items: center;
+ color: var(--primary);
+ background: color-mix(in oklch, var(--primary) 10%, transparent);
+ border-radius: var(--ds-radius-control);
+}
+
+.ui-lab-card-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--ds-space-module-inset);
+}
+
+.ui-lab-card-heading strong {
+ font-size: var(--ds-type-dialog-size);
+ font-weight: var(--ds-font-weight-medium);
+}
+
+.ui-lab-card-description {
+ max-width: 38ch;
+ font-size: var(--ds-type-body-size);
+ line-height: var(--ds-type-prose-leading);
+}
+
+.ui-lab-card-footer {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--ds-space-module-inset);
+ margin-top: auto;
+}
+
+.ui-lab-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--ds-space-inline);
+}
+
+.ui-lab-tags > span,
+.ui-lab-fixture-badge,
+.ui-lab-dev-badge {
+ display: inline-flex;
+ min-height: 22px;
+ align-items: center;
+ padding-inline: var(--ds-space-control-group);
+ border-radius: var(--ds-radius-control);
+ font-size: var(--ds-type-caption-size);
+ font-weight: var(--ds-font-weight-medium);
+ line-height: var(--ds-type-caption-leading);
+ white-space: nowrap;
+}
+
+.ui-lab-tags > span {
+ color: var(--muted-foreground);
+ background: var(--ds-color-fill-quiet);
+}
+
+.ui-lab-fixture-badge {
+ color: var(--ds-color-warning);
+ background: color-mix(in oklch, var(--warning) 14%, transparent);
+}
+
+.ui-lab-dev-badge {
+ color: var(--primary);
+ background: color-mix(in oklch, var(--primary) 10%, transparent);
+}
+
+.ui-lab-preview-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--ds-space-section);
+}
+
+.ui-lab-preview-card {
+ display: grid;
+ min-height: 78px;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--ds-space-surface-inset);
+ padding: var(--ds-space-section);
+ color: var(--foreground);
+ background: var(--ds-color-fill-quiet);
+ border-radius: var(--ds-radius-module);
+ text-decoration: none;
+}
+
+.ui-lab-preview-card:hover {
+ background: var(--ds-color-fill-hover);
+}
+
+.ui-lab-preview-card > span:nth-child(2) {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: var(--ds-space-optical);
+}
+
+.ui-lab-preview-card strong {
+ font-size: var(--ds-type-body-size);
+ font-weight: var(--ds-font-weight-medium);
+}
+
+.ui-lab-scenario {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ grid-template-rows: 48px minmax(0, 1fr);
+ overflow: hidden;
+ color: var(--foreground);
+ background: var(--background);
+}
+
+.ui-lab-scenario-toolbar {
+ position: relative;
+ z-index: 20;
+ min-width: 0;
+ height: 48px;
+ padding-inline: var(--ds-space-surface-inset);
+ background: var(--ds-color-surface);
+ border-bottom: 1px solid var(--border);
+ box-shadow: var(--ds-elevation-surface);
+}
+
+.ui-lab-back {
+ display: inline-flex;
+ min-height: var(--ds-control-normal);
+ align-items: center;
+ gap: var(--ds-space-control-group);
+ padding-inline: var(--ds-space-module-inset);
+ color: var(--foreground);
+ border-radius: var(--ds-radius-control);
+ font-size: var(--ds-type-metadata-size);
+ font-weight: var(--ds-font-weight-medium);
+ text-decoration: none;
+}
+
+.ui-lab-back:hover {
+ background: var(--ds-color-fill-hover);
+}
+
+.ui-lab-scenario-title {
+ overflow: hidden;
+ font-size: var(--ds-type-metadata-size);
+ font-weight: var(--ds-font-weight-medium);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ui-lab-scenario-spacer {
+ min-width: 0;
+ flex: 1;
+}
+
+.ui-lab-scenario-content {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.ui-lab-dock-layout {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ flex: 1;
+ overflow: hidden;
+}
+
+.ui-lab-dock-layout .dock-panel-side > div:last-child {
+ width: 100% !important;
+}
+
+.ui-lab-conversation {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ flex: 1;
+ flex-direction: column;
+ background: var(--background);
+}
+
+.ui-lab-conversation > header {
+ display: flex;
+ min-height: var(--ds-titlebar-height);
+ align-items: center;
+ padding-inline: var(--ds-space-page);
+ border-bottom: 1px solid var(--border);
+}
+
+.ui-lab-conversation > header > div {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: var(--ds-space-optical);
+}
+
+.ui-lab-conversation > header strong {
+ font-size: var(--ds-type-body-size);
+ font-weight: var(--ds-font-weight-medium);
+}
+
+.ui-lab-conversation > header span {
+ overflow: hidden;
+ color: var(--muted-foreground);
+ font-family: var(--ds-font-mono);
+ font-size: var(--ds-type-caption-size);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ui-lab-transcript {
+ display: grid;
+ width: min(100%, 760px);
+ gap: var(--ds-space-page);
+ margin-inline: auto;
+ padding: 44px var(--ds-space-page);
+}
+
+.ui-lab-message {
+ display: grid;
+ gap: var(--ds-space-module-inset);
+}
+
+.ui-lab-message > span {
+ color: var(--muted-foreground);
+ font-size: var(--ds-type-caption-size);
+ font-weight: var(--ds-font-weight-medium);
+ letter-spacing: 0.08em;
+}
+
+.ui-lab-message p {
+ margin: 0;
+ font-size: var(--ds-type-body-size);
+ line-height: var(--ds-type-prose-leading);
+}
+
+.ui-lab-message-user {
+ width: min(100%, 520px);
+ justify-self: end;
+ padding: var(--ds-space-section);
+ background: var(--ds-color-fill-quiet);
+ border-radius: var(--ds-radius-modal);
+}
+
+.ui-lab-message-assistant {
+ width: min(100%, 620px);
+}
+
+.ui-lab-run-summary {
+ display: flex;
+ min-height: var(--ds-control-field);
+ align-items: center;
+ gap: var(--ds-space-module-inset);
+ padding-inline: var(--ds-space-surface-inset);
+ color: var(--muted-foreground);
+ background: var(--ds-color-fill-quiet);
+ border-radius: var(--ds-radius-control);
+ font-size: var(--ds-type-metadata-size);
+}
+
+.ui-lab-composer {
+ display: flex;
+ min-height: 54px;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--ds-space-module-inset);
+ margin: 0 var(--ds-space-page) var(--ds-space-page);
+ padding-inline: var(--ds-space-section);
+ color: var(--muted-foreground);
+ background: var(--ds-color-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--ds-radius-modal);
+ box-shadow: var(--ds-elevation-surface);
+ font-size: var(--ds-type-body-size);
+}
+
+.ui-lab-composer kbd {
+ font-family: var(--ds-font-mono);
+ font-size: var(--ds-type-caption-size);
+}
+
+@media (max-width: 900px) {
+ .ui-lab-scenario-title,
+ .ui-lab-scenario-toolbar > .ui-lab-fixture-badge {
+ display: none;
+ }
+
+ .ui-lab-header {
+ flex-direction: column;
+ padding-bottom: var(--ds-space-page-section);
+ }
+}
+
+@media (max-width: 760px) {
+ .ui-lab-root {
+ grid-template-columns: 1fr;
+ grid-template-rows: 60px minmax(0, 1fr);
+ }
+
+ .ui-lab-sidebar {
+ flex-direction: row;
+ align-items: center;
+ padding: var(--ds-space-module-inset) var(--ds-space-surface-inset);
+ }
+
+ .ui-lab-sidebar nav {
+ margin-inline-start: auto;
+ }
+
+ .ui-lab-sidebar nav a:not([aria-current="page"]),
+ .ui-lab-sidebar-note {
+ display: none;
+ }
+
+ .ui-lab-main {
+ padding: var(--ds-space-section) var(--ds-space-surface-inset);
+ }
+
+ .ui-lab-header {
+ padding-block: var(--ds-space-section) var(--ds-space-page-section);
+ }
+
+ .ui-lab-header h1 {
+ font-size: 28px;
+ }
+
+ .ui-lab-section-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: var(--ds-space-control-group);
+ }
+
+ .ui-lab-preview-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ui-lab-conversation {
+ display: none;
+ }
+
+ .ui-lab-dock-layout .dock-panel-side {
+ width: 100% !important;
+ border-inline-start: 0;
+ }
+
+}
+
+@media (max-width: 520px) {
+ .ui-lab-scenario-toolbar > .ui-lab-dev-badge,
+ .ui-lab-locale-links > svg {
+ display: none;
+ }
+
+ .ui-lab-scenario-toolbar {
+ gap: var(--ds-space-inline);
+ padding-inline: var(--ds-space-module-inset);
+ }
+
+ .ui-lab-back span {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html:not([data-reduce-motion="off"]) .ui-lab-root *,
+ html:not([data-reduce-motion="off"]) .ui-lab-scenario * {
+ scroll-behavior: auto;
+ transition-duration: 0ms;
+ }
+}
diff --git a/apps/desktop/src/dock/Dock.tsx b/apps/desktop/src/dock/Dock.tsx
index 6559a321..80969325 100644
--- a/apps/desktop/src/dock/Dock.tsx
+++ b/apps/desktop/src/dock/Dock.tsx
@@ -3,6 +3,7 @@ import {
Activity,
FolderTree,
GitBranch,
+ GitPullRequest,
Globe,
MessageSquare,
TerminalIcon,
@@ -15,7 +16,14 @@ import { useResizeHandle } from "@/components/ui/use-resize-handle";
import { useT } from "../i18n";
import { cn } from "@/lib/utils";
-export type DockSurface = "trajectory" | "terminal" | "browser" | "side-chat" | "files" | "git";
+export type DockSurface =
+ | "trajectory"
+ | "terminal"
+ | "browser"
+ | "side-chat"
+ | "files"
+ | "git"
+ | "pull-request";
/** "home" is the dock open with nothing chosen yet — the surface picker. */
export type DockTab = DockSurface | "home";
export type DockContentMap = Partial>;
@@ -53,6 +61,7 @@ const SURFACES: DockSurfaceDefinition[] = [
{ id: "side-chat", icon: MessageSquare, titleKey: "sideChat.title", descKey: "sideChat.temporary" },
{ id: "files", icon: FolderTree, titleKey: "dock.files", descKey: "dock.filesDesc" },
{ id: "git", icon: GitBranch, titleKey: "dock.git", descKey: "dock.gitDesc" },
+ { id: "pull-request", icon: GitPullRequest, titleKey: "dock.pullRequest", descKey: "dock.pullRequestDesc" },
];
export const DOCK_MIN_WIDTH = 300;
@@ -80,7 +89,7 @@ export function Dock({
onWidth,
reservedWidth = 0,
autoTab,
- availableSurfaces = ["trajectory", "browser", "terminal", "files", "git"],
+ availableSurfaces = ["trajectory", "browser", "terminal", "files", "git", "pull-request"],
content = {},
}: DockProps) {
const t = useT();
diff --git a/apps/desktop/src/git/GitDockContent.tsx b/apps/desktop/src/git/GitDockContent.tsx
index 63144103..17163552 100644
--- a/apps/desktop/src/git/GitDockContent.tsx
+++ b/apps/desktop/src/git/GitDockContent.tsx
@@ -9,17 +9,19 @@ import { GitHubPullRequestPanel } from "./GitHubPullRequestPanel";
import { GitSyncStatus } from "./GitSyncStatus";
type GitDockContentProps = {
+ status: GitStatus | null;
+ onOpenSourceControl: () => void;
+};
+
+type PullRequestDockContentProps = {
cwd: string | null;
status: GitStatus | null;
onRefresh: () => void;
- onOpenSourceControl: () => void;
};
-/** Source-control summary rendered inside the generic Dock container. */
+/** Working-tree summary rendered inside the generic Dock container. */
export function GitDockContent({
- cwd,
status,
- onRefresh,
onOpenSourceControl,
}: GitDockContentProps) {
const t = useT();
@@ -28,54 +30,71 @@ export function GitDockContent({
{status?.is_repo ? (
- <>
-
+
+
+
+
{t("dock.workingTree")}
+
+ {status.branch || "?"}
+
+
+
-
-
-
-
{t("dock.workingTree")}
-
- {status.branch || "?"}
-
-
+ {status.files.length === 0 ? (
+
{t("rail.clean")}
+ ) : (
+
+ {status.files.map((file) => (
+
+
+ {file.state.charAt(0).toUpperCase()}
+
+
+ {file.path}
+
+
+ ))}
+ )}
+
+
+
+ ) : (
+
{t("rail.notARepo")}
+ )}
+
+
+ );
+}
- {status.files.length === 0 ? (
- {t("rail.clean")}
- ) : (
-
- {status.files.map((file) => (
-
-
- {file.state.charAt(0).toUpperCase()}
-
-
- {file.path}
-
-
- ))}
-
- )}
+/** Current-branch pull request rendered as its own conversation-side Dock surface. */
+export function PullRequestDockContent({
+ cwd,
+ status,
+ onRefresh,
+}: PullRequestDockContentProps) {
+ const t = useT();
-
-
- >
+ return (
+
+
+ {status?.is_repo ? (
+
) : (
{t("rail.notARepo")}
)}
diff --git a/apps/desktop/src/github/PullRequestsPage.tsx b/apps/desktop/src/github/PullRequestsPage.tsx
index da03f40b..23f2c733 100644
--- a/apps/desktop/src/github/PullRequestsPage.tsx
+++ b/apps/desktop/src/github/PullRequestsPage.tsx
@@ -9,7 +9,9 @@ import {
ExternalLink,
FileCode2,
GitBranch,
+ GitMerge,
GitPullRequest,
+ ListChecks,
MessageCircle,
RefreshCw,
Search,
@@ -30,8 +32,8 @@ import {
import { ActivityOrb } from "@/components/ui/activity-orb";
import { Button } from "@/components/ui/button";
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
-import { DetailMetric } from "@/components/business/detail-metric";
import { MasterDetailRow } from "@/components/business/master-detail-row";
+import { StatusBadge, type StatusTone } from "@/components/business/status-badge";
import { Spinner } from "@/components/ui/spinner";
import {
DropdownMenu,
@@ -50,8 +52,11 @@ import {
filterPullRequests,
githubPullRequestReference,
groupPullRequests,
+ pullRequestCheckResult,
pullRequestCheckState,
+ pullRequestMergeReadiness,
shortPullRequestAge,
+ type PullRequestMergeReadiness,
type PullRequestReadiness,
type PullRequestView,
} from "./pullRequests";
@@ -151,6 +156,31 @@ function PullRequestBody({ body }: { body: string }) {
return
{blocks}
;
}
+function readinessTone(readiness: PullRequestMergeReadiness): StatusTone {
+ if (readiness === "ready") return "success";
+ if (
+ readiness === "conflicting"
+ || readiness === "checks_failed"
+ || readiness === "changes_requested"
+ ) return "destructive";
+ if (
+ readiness === "draft"
+ || readiness === "checks_pending"
+ || readiness === "review_required"
+ || readiness === "pending"
+ ) return "warning";
+ return "neutral";
+}
+
+function reviewerStateKey(state: string): "approved" | "changes_requested" | "commented" | "dismissed" | "pending" {
+ const normalized = state.toLocaleLowerCase();
+ if (normalized === "approved") return "approved";
+ if (normalized === "changes_requested") return "changes_requested";
+ if (normalized === "commented") return "commented";
+ if (normalized === "dismissed") return "dismissed";
+ return "pending";
+}
+
function PullRequestRow({ item, selected, onSelect }: {
item: GitHubPullRequestSummary;
selected: boolean;
@@ -219,7 +249,7 @@ export function PullRequestsPage({
const deferredQuery = useDeferredValue(query);
const [selectedId, setSelectedId] = useState
(null);
const [detailState, setDetailState] = useState(null);
- const [detailTab, setDetailTab] = useState<"summary" | "code">("summary");
+ const [detailTab, setDetailTab] = useState<"summary" | "changes" | "checks">("summary");
const [compactListVisible, setCompactListVisible] = useState(true);
const requestRef = useRef(0);
@@ -284,6 +314,7 @@ export function PullRequestsPage({
: null;
const linkTarget = !linkedTask && activeTask?.pullRequest === null ? activeTask : null;
const checkState = detail ? pullRequestCheckState(detail) : "none";
+ const mergeReadiness = detail ? pullRequestMergeReadiness(detail) : "pending";
const groupLabel = (id: "review-requested" | "reviewed" | "authored") => t(`pullRequests.group.${id}`);
const readinessLabel = t(`pullRequests.filter.${readiness}`);
@@ -381,7 +412,7 @@ export function PullRequestsPage({
{selectedId && }
setDetailTab(value as typeof detailTab)} className="gap-0">
- {(["summary", "code"] as const).map((id) => (
+ {(["summary", "changes", "checks"] as const).map((id) => (
{t(`pullRequests.detail.${id}`)}
))}
@@ -395,11 +426,12 @@ export function PullRequestsPage({
) : null}
{onUnlinkTask ? (
@@ -427,15 +459,17 @@ export function PullRequestsPage({
) : null}
-
+
+
>}
{!selected ? (
@@ -445,42 +479,117 @@ export function PullRequestsPage({
) : detailState?.error ? (
{detailState.error}
) : detail ? (
-
- {detailTab === "summary" ? (
+
+
-
+
{detail.repository.nameWithOwner} / #{detail.number}
+
{detail.title}
-
{avatar(detail.author.login)}{detail.author.login}·{shortPullRequestAge(detail.createdAt)}·{detail.repository.nameWithOwner} #{detail.number}
+
+
+
+ {detail.isDraft ? t("pullRequests.draft") : t("pullRequests.state.open")}
+
+ {avatar(detail.author.login)}
+ {detail.author.login}
+ ·
+ {shortPullRequestAge(detail.createdAt)}
+
{detailState?.loading &&
}
-
-
} label={t("pullRequests.branch")}>
{detail.headRefName}{detail.baseRefName}+{detail.additions.toLocaleString()}−{detail.deletions.toLocaleString()}
-
} label={t("pullRequests.reviewers")}>{detail.reviewers.length ? detail.reviewers.map((reviewer) => `${reviewer.login} · ${reviewer.state.toLocaleLowerCase().replaceAll("_", " ")}`).join(", ") : t("pullRequests.noReviewers")}
-
} label={t("pullRequests.comments")}>{detail.commentsCount === 0 ? t("pullRequests.noComments") : t("pullRequests.commentCount", { count: detail.commentsCount })}
-
: checkState === "failed" ?
:
} label={t("pullRequests.checks")}>{checkState === "none" ? t("pullRequests.noChecks") : checkState === "passed" ? t("pullRequests.checksPassed", { count: detail.checks.length }) : checkState === "failed" ? t("pullRequests.checksFailed", { count: detail.checks.filter((check) => check.conclusion === "FAILURE").length || 1 }) : t("pullRequests.checksPending", { count: detail.checks.length })}
-
} label={t("pullRequests.status")}>
{detail.isDraft ? t("pullRequests.draft") : detail.state.toLocaleLowerCase()}
+
+
+ {detail.headRefName}
+
+ {detail.baseRefName}
+ +{detail.additions.toLocaleString()}
+ −{detail.deletions.toLocaleString()}
-
- {t("pullRequests.description")}
- {detail.body.trim() ? : {t("pullRequests.noDescription")}
}
-
+
+ {detailTab === "summary" ? (
+
+ {t("pullRequests.description")}
+ {detail.body.trim() ? : {t("pullRequests.noDescription")}
}
+
+ ) : detailTab === "changes" ? (
+
+
{t("pullRequests.changedFiles", { count: detail.changedFiles })}
+
+ {detail.files.map((file) => (
+
+ {file.path}
+ +{file.additions}−{file.deletions}
+
+ ))}
+
+
+ ) : (
+
+
{t("pullRequests.checks")}
+ {detail.checks.length === 0 ? {t("pullRequests.noChecks")}
: (
+
+ {detail.checks.map((check) => {
+ const result = pullRequestCheckResult(check);
+ const failed = result === "failed";
+ const passed = result === "passed";
+ return (
+
+ {passed ? : failed ? : }
+ {check.name}
+
+ {passed ? t("pullRequests.checkStatus.success") : failed ? t("pullRequests.checkStatus.failure") : t("pullRequests.checkStatus.pending")}
+
+ {check.detailsUrl ? : null}
+
+ );
+ })}
+
+ )}
+
+ )}
- ) : (
-
-
{t("pullRequests.changedFiles", { count: detail.changedFiles })}
-
- {detail.files.map((file) => (
-
-
{file.path}
-
+{file.additions}−{file.deletions}
+
+
+
- )}
-
+
+
+
) : null}
diff --git a/apps/desktop/src/github/pull-requests.css b/apps/desktop/src/github/pull-requests.css
index 6637a883..c32fbc1c 100644
--- a/apps/desktop/src/github/pull-requests.css
+++ b/apps/desktop/src/github/pull-requests.css
@@ -6,6 +6,22 @@
width: clamp(20rem, 38cqw, 31rem);
}
+.pull-request-detail-workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) clamp(12rem, 23cqw, 16rem);
+}
+
+.pull-request-inspector {
+ min-width: 0;
+ margin: var(--ds-space-surface-inset);
+ margin-inline-start: 0;
+ overflow: hidden;
+ background: var(--ds-color-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--ds-radius-modal);
+ box-shadow: var(--ds-elevation-raised);
+}
+
.pull-request-body {
font-size: var(--ds-type-body-size);
line-height: var(--ds-type-prose-leading);
@@ -59,6 +75,21 @@
display: none;
}
+@container (max-width: 60rem) {
+ .pull-request-detail-workspace {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .pull-request-inspector {
+ display: none;
+ }
+
+ .pull-request-secondary-action-label,
+ .pull-request-primary-action-label {
+ display: none;
+ }
+}
+
@container (max-width: 44rem) {
.pull-requests-list-pane {
width: 100%;
@@ -75,4 +106,8 @@
.pull-request-back {
display: inline-flex;
}
+
+ .pull-requests-page[data-compact-detail="true"] .pull-request-primary-action-label {
+ display: inline;
+ }
}
diff --git a/apps/desktop/src/github/pullRequests.ts b/apps/desktop/src/github/pullRequests.ts
index f4183f43..e200b7c7 100644
--- a/apps/desktop/src/github/pullRequests.ts
+++ b/apps/desktop/src/github/pullRequests.ts
@@ -3,6 +3,17 @@ import type { GitHubPullRequestReference } from "../taskboard/taskBoard";
export type PullRequestView = "all" | "reviewing" | "authored";
export type PullRequestReadiness = "all" | "draft" | "ready";
+export type PullRequestMergeReadiness =
+ | "closed"
+ | "draft"
+ | "conflicting"
+ | "checks_failed"
+ | "checks_pending"
+ | "changes_requested"
+ | "review_required"
+ | "ready"
+ | "pending";
+export type PullRequestCheckResult = "passed" | "failed" | "pending";
export interface PullRequestGroup {
id: "review-requested" | "reviewed" | "authored";
@@ -83,13 +94,45 @@ export function pullRequestCheckState(detail: GitHubPullRequestDetail):
| "failed"
| "passed" {
if (detail.checks.length === 0) return "none";
- if (detail.checks.some((check) =>
- ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"].includes(
- check.conclusion.toLocaleUpperCase() || check.status.toLocaleUpperCase(),
- ))) return "failed";
- if (detail.checks.some((check) =>
- !check.conclusion || ["QUEUED", "IN_PROGRESS", "PENDING", "EXPECTED"].includes(
- check.status.toLocaleUpperCase(),
- ))) return "pending";
+ if (detail.checks.some((check) => pullRequestCheckResult(check) === "failed")) return "failed";
+ if (detail.checks.some((check) => pullRequestCheckResult(check) === "pending")) return "pending";
return "passed";
}
+
+export function pullRequestCheckResult(
+ check: GitHubPullRequestDetail["checks"][number],
+): PullRequestCheckResult {
+ const conclusion = check.conclusion.toLocaleUpperCase();
+ if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) return "passed";
+ if ([
+ "FAILURE",
+ "ERROR",
+ "CANCELLED",
+ "TIMED_OUT",
+ "ACTION_REQUIRED",
+ "STALE",
+ "STARTUP_FAILURE",
+ ].includes(conclusion)) return "failed";
+ return "pending";
+}
+
+export function pullRequestMergeReadiness(
+ detail: GitHubPullRequestDetail,
+): PullRequestMergeReadiness {
+ if (detail.state.toLocaleUpperCase() !== "OPEN") return "closed";
+ if (detail.isDraft) return "draft";
+ if (
+ detail.mergeable.toLocaleUpperCase() === "CONFLICTING"
+ || detail.mergeStateStatus.toLocaleUpperCase() === "DIRTY"
+ ) return "conflicting";
+
+ const checks = pullRequestCheckState(detail);
+ if (checks === "failed") return "checks_failed";
+
+ const review = detail.reviewDecision.toLocaleUpperCase();
+ if (review === "CHANGES_REQUESTED") return "changes_requested";
+ if (checks === "pending") return "checks_pending";
+ if (review === "REVIEW_REQUIRED") return "review_required";
+ if (detail.mergeable.toLocaleUpperCase() === "MERGEABLE") return "ready";
+ return "pending";
+}
diff --git a/apps/desktop/src/i18n/index.tsx b/apps/desktop/src/i18n/index.tsx
index d95aee0f..d78b0c59 100644
--- a/apps/desktop/src/i18n/index.tsx
+++ b/apps/desktop/src/i18n/index.tsx
@@ -1,4 +1,4 @@
-import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { LOCALES, type Locale, type StringKey } from "./strings";
@@ -50,27 +50,42 @@ const I18nContext = createContext
({
t: (k) => k,
});
-export function I18nProvider({ children }: { children: ReactNode }) {
- const [preference, setPreferenceState] = useState(storedPreference);
+export function I18nProvider({
+ children,
+ preferenceOverride,
+}: {
+ children: ReactNode;
+ /** A non-persistent preview value. UI Lab uses this without changing the user's app setting. */
+ preferenceOverride?: LanguagePreference;
+}) {
+ const [storedPreferenceState, setPreferenceState] = useState(storedPreference);
+ const preference = preferenceOverride ?? storedPreferenceState;
const locale: Locale = preference === "system" ? resolveSystemLocale() : preference;
+ useEffect(() => {
+ const root = document.documentElement;
+ const previous = root.lang;
+ root.lang = locale;
+ return () => {
+ root.lang = previous;
+ };
+ }, [locale]);
+
const t = useMemo(() => {
const table = LOCALES[locale].strings;
return (key, vars) => interpolate(table[key] ?? key, vars);
}, [locale]);
const setPreference = useCallback((p: LanguagePreference) => {
+ if (preferenceOverride !== undefined) return;
setPreferenceState(p);
try {
localStorage.setItem(STORAGE_KEY, p);
} catch {
/* private mode — the choice just won't survive a restart */
}
- // `lang` drives font fallback and hyphenation; leaving it as "en" makes CJK text render with
- // the wrong face on some systems.
- document.documentElement.lang = p === "system" ? resolveSystemLocale() : p;
- }, []);
+ }, [preferenceOverride]);
return (
diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts
index 52e40395..d86a877e 100644
--- a/apps/desktop/src/i18n/strings.ts
+++ b/apps/desktop/src/i18n/strings.ts
@@ -1693,6 +1693,7 @@ export const en = {
"dock.terminal": "Terminal",
"dock.browser": "Browser",
"dock.git": "Git",
+ "dock.pullRequest": "PR",
"dock.close": "Close panel",
"dock.resize": "Drag to resize the panel",
"dock.newTerminal": "New terminal",
@@ -1706,6 +1707,7 @@ export const en = {
"dock.browserDesc": "Open a local app or URL.",
"dock.filesDesc": "Browse and edit workspace files.",
"dock.gitDesc": "Review changes in the working tree.",
+ "dock.pullRequestDesc": "Inspect and review the pull request for this branch.",
"dock.trajectoryDesc": "Inspect the session timeline, events, and execution details.",
"dock.maximize": "Widen the panel",
"dock.restore": "Restore the panel width",
@@ -2426,7 +2428,8 @@ export const en = {
"pullRequests.view.authored": "Authored",
"pullRequests.detailViews": "Pull request detail views",
"pullRequests.detail.summary": "Summary",
- "pullRequests.detail.code": "Code",
+ "pullRequests.detail.changes": "Changes",
+ "pullRequests.detail.checks": "Checks",
"pullRequests.group.review-requested": "Review requested",
"pullRequests.group.reviewed": "Previously reviewed",
"pullRequests.group.authored": "Authored",
@@ -2453,10 +2456,16 @@ export const en = {
"pullRequests.taskLinked": "Linked this pull request to \"{title}\".",
"pullRequests.taskUnlinked": "Unlinked this pull request from \"{title}\".",
"pullRequests.taskLinkChanged": "The task link changed. Refresh and try again.",
- "pullRequests.chat": "Chat",
+ "pullRequests.chat": "Join conversation",
+ "pullRequests.reviewChanges": "Review changes",
"pullRequests.branch": "Branch",
"pullRequests.reviewers": "Reviewers",
"pullRequests.noReviewers": "No reviewers",
+ "pullRequests.reviewState.approved": "approved",
+ "pullRequests.reviewState.changes_requested": "changes requested",
+ "pullRequests.reviewState.commented": "commented",
+ "pullRequests.reviewState.dismissed": "dismissed",
+ "pullRequests.reviewState.pending": "pending",
"pullRequests.comments": "Comments",
"pullRequests.noComments": "No comments",
"pullRequests.commentCount": "{count} comments",
@@ -2467,6 +2476,28 @@ export const en = {
"pullRequests.checksPending": "{count} checks running",
"pullRequests.status": "Status",
"pullRequests.draft": "Draft",
+ "pullRequests.state.open": "Open",
+ "pullRequests.inspector": "Pull request status",
+ "pullRequests.mergeReadiness": "Merge readiness",
+ "pullRequests.readiness.closed": "Closed",
+ "pullRequests.readiness.draft": "Draft",
+ "pullRequests.readiness.conflicting": "Resolve conflicts",
+ "pullRequests.readiness.checks_failed": "Checks failed",
+ "pullRequests.readiness.checks_pending": "Checks running",
+ "pullRequests.readiness.changes_requested": "Changes requested",
+ "pullRequests.readiness.review_required": "Review required",
+ "pullRequests.readiness.ready": "Ready to merge",
+ "pullRequests.readiness.pending": "Merge status pending",
+ "pullRequests.linkedTask": "Linked task",
+ "pullRequests.noLinkedTask": "No linked task",
+ "pullRequests.labels": "Labels",
+ "pullRequests.noLabels": "No labels",
+ "pullRequests.activity": "Activity",
+ "pullRequests.updated": "Updated {age}",
+ "pullRequests.checkStatus.success": "Passed",
+ "pullRequests.checkStatus.failure": "Failed",
+ "pullRequests.checkStatus.pending": "Running",
+ "pullRequests.openCheck": "Open {name}",
"pullRequests.description": "Description",
"pullRequests.noDescription": "No description provided.",
"pullRequests.changedFiles": "{count} changed files",
@@ -4192,6 +4223,7 @@ export const zhCN: Record = {
"dock.terminal": "终端",
"dock.browser": "浏览器",
"dock.git": "Git",
+ "dock.pullRequest": "PR",
"dock.close": "关闭面板",
"dock.resize": "拖动调整面板宽度",
"dock.newTerminal": "新建终端",
@@ -4205,6 +4237,7 @@ export const zhCN: Record = {
"dock.browserDesc": "打开本地应用或网址。",
"dock.filesDesc": "浏览、编辑工作区的文件。",
"dock.gitDesc": "查看工作区的改动。",
+ "dock.pullRequestDesc": "查看并审阅当前分支的 pull request。",
"dock.trajectoryDesc": "查看当前会话的时间线、事件和执行详情。",
"dock.maximize": "加宽面板",
"dock.restore": "恢复面板宽度",
@@ -4888,7 +4921,8 @@ export const zhCN: Record = {
"pullRequests.view.authored": "我创建的",
"pullRequests.detailViews": "Pull request 详情视图",
"pullRequests.detail.summary": "摘要",
- "pullRequests.detail.code": "代码",
+ "pullRequests.detail.changes": "变更",
+ "pullRequests.detail.checks": "检查",
"pullRequests.group.review-requested": "待我审阅",
"pullRequests.group.reviewed": "已审阅",
"pullRequests.group.authored": "我创建的",
@@ -4915,10 +4949,16 @@ export const zhCN: Record = {
"pullRequests.taskLinked": "已把这个 pull request 关联到“{title}”。",
"pullRequests.taskUnlinked": "已解除这个 pull request 与“{title}”的关联。",
"pullRequests.taskLinkChanged": "任务关联已发生变化,请刷新后重试。",
- "pullRequests.chat": "对话",
+ "pullRequests.chat": "加入对话",
+ "pullRequests.reviewChanges": "审阅变更",
"pullRequests.branch": "分支",
"pullRequests.reviewers": "审阅者",
"pullRequests.noReviewers": "没有审阅者",
+ "pullRequests.reviewState.approved": "已批准",
+ "pullRequests.reviewState.changes_requested": "要求修改",
+ "pullRequests.reviewState.commented": "已评论",
+ "pullRequests.reviewState.dismissed": "已撤销",
+ "pullRequests.reviewState.pending": "待处理",
"pullRequests.comments": "评论",
"pullRequests.noComments": "没有评论",
"pullRequests.commentCount": "{count} 条评论",
@@ -4929,6 +4969,28 @@ export const zhCN: Record = {
"pullRequests.checksPending": "{count} 项检查运行中",
"pullRequests.status": "状态",
"pullRequests.draft": "草稿",
+ "pullRequests.state.open": "打开",
+ "pullRequests.inspector": "Pull request 状态",
+ "pullRequests.mergeReadiness": "合并准备度",
+ "pullRequests.readiness.closed": "已关闭",
+ "pullRequests.readiness.draft": "草稿",
+ "pullRequests.readiness.conflicting": "需要解决冲突",
+ "pullRequests.readiness.checks_failed": "检查失败",
+ "pullRequests.readiness.checks_pending": "检查运行中",
+ "pullRequests.readiness.changes_requested": "需要修改",
+ "pullRequests.readiness.review_required": "需要审阅",
+ "pullRequests.readiness.ready": "可以合并",
+ "pullRequests.readiness.pending": "正在确认合并状态",
+ "pullRequests.linkedTask": "关联任务",
+ "pullRequests.noLinkedTask": "未关联任务",
+ "pullRequests.labels": "标签",
+ "pullRequests.noLabels": "没有标签",
+ "pullRequests.activity": "活动",
+ "pullRequests.updated": "更新于 {age}",
+ "pullRequests.checkStatus.success": "已通过",
+ "pullRequests.checkStatus.failure": "失败",
+ "pullRequests.checkStatus.pending": "运行中",
+ "pullRequests.openCheck": "打开 {name}",
"pullRequests.description": "描述",
"pullRequests.noDescription": "未提供描述。",
"pullRequests.changedFiles": "{count} 个变更文件",
diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx
index f02ba34a..ce529a27 100644
--- a/apps/desktop/src/main.tsx
+++ b/apps/desktop/src/main.tsx
@@ -23,6 +23,16 @@ const showPetPreview =
import.meta.env.DEV && searchParams.has("pet-preview");
const showRichTranscript =
import.meta.env.DEV && searchParams.has("rich-transcript");
+const uiLabRoute = import.meta.env.DEV ? searchParams.get("ui-lab") : null;
+const showUiLab = uiLabRoute !== null;
+const uiLabThemeOverride = showUiLab
+ ? searchParams.get("theme") === "light" || searchParams.get("theme") === "dark"
+ ? searchParams.get("theme") as "light" | "dark"
+ : "system"
+ : undefined;
+const uiLabLanguageOverride = showUiLab
+ ? searchParams.get("lang") === "zh" ? "zh-CN" : "en"
+ : undefined;
if (showDesktopPet) document.documentElement.classList.add("desktop-pet-window-root");
// The webview's own menu (Reload / Inspect Element) is a browser artefact, not something a desktop
@@ -63,7 +73,9 @@ if (!showDesktopPet && currentDesktopPlatform() === "macos") {
async function render() {
const Root = showDesktopPet
? DesktopPetWindow
- : showPetPreview
+ : showUiLab
+ ? (await import("./design/ui-lab/UiLab")).UiLab
+ : showPetPreview
? (await import("./pet/PetPreview")).PetPreview
: showRichTranscript
? (await import("./session/RichTranscriptPreview")).RichTranscriptPreview
@@ -73,8 +85,8 @@ async function render() {
ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
+
+
diff --git a/apps/desktop/src/theme.tsx b/apps/desktop/src/theme.tsx
index a07c963e..dc7ea00b 100644
--- a/apps/desktop/src/theme.tsx
+++ b/apps/desktop/src/theme.tsx
@@ -36,9 +36,16 @@ const ThemeContext = createContext({
* at sunset while the app is open. An explicit light/dark must *stop* listening, or the user's
* choice would be silently overridden the next time the OS changed its mind.
*/
-export function ThemeProvider({ children }: { children: ReactNode }) {
+export function ThemeProvider({
+ children,
+ preferenceOverride,
+}: {
+ children: ReactNode;
+ /** A non-persistent preview value. UI Lab uses this without changing the user's app setting. */
+ preferenceOverride?: ThemePreference;
+}) {
const appearance = useAppearanceSettings();
- const preference = appearance.preference;
+ const preference = preferenceOverride ?? appearance.preference;
const [system, setSystem] = useState(systemScheme);
useEffect(() => {
@@ -72,8 +79,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [appearance, scheme]);
const setPreference = useCallback((p: ThemePreference) => {
+ if (preferenceOverride !== undefined) return;
setAppearanceSettings({ preference: p });
- }, []);
+ }, [preferenceOverride]);
return (
{children}
diff --git a/apps/desktop/tests/dockArchitecture.test.ts b/apps/desktop/tests/dockArchitecture.test.ts
index 8537ad4c..452e817f 100644
--- a/apps/desktop/tests/dockArchitecture.test.ts
+++ b/apps/desktop/tests/dockArchitecture.test.ts
@@ -30,11 +30,15 @@ describe("Dock container and content seam", () => {
expect(app).toContain(" {
activateDom();
const opened = [];
const view = renderDock(
- ["trajectory", "browser", "terminal", "side-chat", "files", "git"],
+ ["trajectory", "browser", "terminal", "side-chat", "files", "git", "pull-request"],
"home",
true,
(surface) => opened.push(surface),
@@ -83,6 +83,7 @@ describe("Dock plugin component gate", () => {
const cards = Array.from(view.container.querySelectorAll(".dock-surface-grid > button"));
expect(cards[2]?.textContent).toContain("Terminal");
expect(cards[3]?.getAttribute("aria-label")).toBe("Side chat");
+ expect(cards[6]?.getAttribute("aria-label")).toBe("PR");
expect(cards.every((card) => card.classList.contains("dock-surface-card"))).toBe(true);
expect(cards.every((card) => card.classList.contains("bg-card"))).toBe(true);
expect(cards.every((card) => card.classList.contains("p-3"))).toBe(true);
diff --git a/apps/desktop/tests/githubPullRequests.test.ts b/apps/desktop/tests/githubPullRequests.test.ts
index 86a05db8..0c034617 100644
--- a/apps/desktop/tests/githubPullRequests.test.ts
+++ b/apps/desktop/tests/githubPullRequests.test.ts
@@ -4,7 +4,9 @@ import {
filterPullRequests,
githubPullRequestReference,
groupPullRequests,
+ pullRequestCheckResult,
pullRequestCheckState,
+ pullRequestMergeReadiness,
shortPullRequestAge,
} from "../src/github/pullRequests";
import type { GitHubPullRequestDetail, GitHubPullRequestSummary } from "../src/bridge";
@@ -85,6 +87,17 @@ describe("GitHub pull request projections", () => {
expect(pullRequestCheckState({ ...base, checks: [] })).toBe("none");
expect(pullRequestCheckState({ ...base, checks: [{ name: "test", status: "IN_PROGRESS", conclusion: "", detailsUrl: null }] })).toBe("pending");
expect(pullRequestCheckState({ ...base, checks: [{ name: "test", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: null }] })).toBe("failed");
+ expect(pullRequestCheckResult({ name: "neutral", status: "COMPLETED", conclusion: "NEUTRAL", detailsUrl: null })).toBe("passed");
+ expect(pullRequestMergeReadiness({ ...base, checks: [] })).toBe("ready");
+ expect(pullRequestMergeReadiness({ ...base, isDraft: true, checks: [] })).toBe("draft");
+ expect(pullRequestMergeReadiness({ ...base, mergeable: "CONFLICTING", checks: [] })).toBe("conflicting");
+ expect(pullRequestMergeReadiness({ ...base, state: "CLOSED", checks: [] })).toBe("closed");
+ expect(pullRequestMergeReadiness({ ...base, reviewDecision: "REVIEW_REQUIRED", checks: [] })).toBe("review_required");
+ expect(pullRequestMergeReadiness({
+ ...base,
+ reviewDecision: "CHANGES_REQUESTED",
+ checks: [],
+ })).toBe("changes_requested");
});
test("projects the stable GitHub identity stored by task links", () => {
diff --git a/apps/desktop/tests/githubPullRequestsRendered.test.tsx b/apps/desktop/tests/githubPullRequestsRendered.test.tsx
index 718a66d2..4d592c54 100644
--- a/apps/desktop/tests/githubPullRequestsRendered.test.tsx
+++ b/apps/desktop/tests/githubPullRequestsRendered.test.tsx
@@ -15,6 +15,10 @@ const { githubPullRequestReference } = await import("../src/github/pullRequests"
const layoutSpec = JSON.parse(
readFileSync(new URL("../layout-spec.json", import.meta.url), "utf8"),
);
+const pullRequestCss = readFileSync(
+ new URL("../src/github/pull-requests.css", import.meta.url),
+ "utf8",
+);
const mounted = [];
let restoreCanvasContext = null;
@@ -45,7 +49,7 @@ const summary = {
isDraft: false,
updatedAt: "2026-08-24T10:00:00Z",
createdAt: "2026-08-23T10:00:00Z",
- labels: [],
+ labels: [{ name: "enhancement", color: "2f81f7" }],
commentsCount: 2,
authored: true,
reviewRequested: false,
@@ -63,8 +67,8 @@ const detail = {
state: "OPEN",
mergeStateStatus: "CLEAN",
mergeable: "MERGEABLE",
- reviewDecision: "",
- reviewers: [],
+ reviewDecision: "APPROVED",
+ reviewers: [{ login: "reviewer", state: "APPROVED" }],
checks: [{ name: "test", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null }],
files: [{ path: "src/github.ts", additions: 120, deletions: 8, changeType: "MODIFIED" }],
};
@@ -86,7 +90,7 @@ const reviewingDetail = {
};
describe("PullRequestsPage", () => {
- test("loads real data projections, switches code view, and starts chat", async () => {
+ test("renders the PR workspace, reviews changes and checks, and starts chat", async () => {
activateDom();
disableCanvasDrawing();
dom.window.localStorage.setItem("codetwo.language", "en");
@@ -112,6 +116,22 @@ describe("PullRequestsPage", () => {
listContentLine: 32,
listControlsOuterInset: 16,
});
+ expect(layoutSpec.content.workbench.pullRequests).toMatchObject({
+ inspectorMinWidth: 192,
+ inspectorPreferredWidth: "23cqw",
+ inspectorMaxWidth: 256,
+ inspectorCollapseAt: 960,
+ inspectorInset: 12,
+ inspectorRadius: "modal",
+ inspectorElevation: "raised",
+ });
+ expect(pullRequestCss).toContain("@container (max-width: 60rem)");
+ expect(pullRequestCss).toContain(".pull-request-inspector");
+ expect(pullRequestCss).toContain("margin: var(--ds-space-surface-inset)");
+ expect(pullRequestCss).toContain("margin-inline-start: 0");
+ expect(pullRequestCss).toContain("border-radius: var(--ds-radius-modal)");
+ expect(pullRequestCss).toContain("box-shadow: var(--ds-elevation-raised)");
+ expect(pullRequestCss).toContain(".pull-request-secondary-action-label");
expect(listHeader?.className).toContain("pl-page-section");
expect(listHeader?.contains(views)).toBeFalse();
expect(listControls?.contains(views)).toBeTrue();
@@ -125,12 +145,29 @@ describe("PullRequestsPage", () => {
});
expect(dom.document.body.textContent).toContain("1 checks passed");
expect(dom.document.body.textContent).toContain("Added real pull request data");
+ const inspector = view.container.querySelector("[data-pull-request-inspector]");
+ expect(inspector?.getAttribute("aria-label")).toBe("Pull request status");
+ expect(inspector?.className).not.toContain("border-l");
+ expect(inspector?.className).not.toContain("bg-sidebar");
+ expect(inspector?.textContent).toContain("Ready to merge");
+ expect(inspector?.textContent).toContain("reviewer");
+ expect(inspector?.textContent).toContain("enhancement");
+
+ click(button(dom.document.body, "Changes"));
+ await flush();
+ expect(dom.document.body.textContent).toContain("src/github.ts");
+
+ click(button(dom.document.body, "Checks"));
+ await flush();
+ expect(dom.document.body.textContent).toContain("test");
+ expect(dom.document.body.textContent).toContain("Passed");
- click(button(dom.document.body, "Code"));
+ click(button(dom.document.body, "Summary"));
+ click(button(dom.document.body, "Review changes"));
await flush();
expect(dom.document.body.textContent).toContain("src/github.ts");
- click(button(dom.document.body, "Chat"));
+ click(button(dom.document.body, "Join conversation"));
expect(chatted).toEqual(detail);
});
diff --git a/apps/desktop/tests/uiLabRendered.test.tsx b/apps/desktop/tests/uiLabRendered.test.tsx
new file mode 100644
index 00000000..b9225960
--- /dev/null
+++ b/apps/desktop/tests/uiLabRendered.test.tsx
@@ -0,0 +1,136 @@
+// @ts-nocheck
+import { afterEach, describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { act as reactAct } from "react";
+
+import {
+ activateDom,
+ dom,
+ flush,
+ mount,
+} from "./domTestHarness";
+
+activateDom();
+
+const { resetAppearanceSettings, setAppearanceSettings } = await import("../src/appearance");
+const { I18nProvider } = await import("../src/i18n");
+const { ThemeProvider } = await import("../src/theme");
+const { UiLab } = await import("../src/design/ui-lab/UiLab");
+const {
+ loadPullRequest,
+ loadPullRequests,
+ pullRequestPanelApi,
+ pullRequestTasks,
+} = await import("../src/design/ui-lab/fixtures");
+
+const layoutSpec = JSON.parse(
+ readFileSync(new URL("../layout-spec.json", import.meta.url), "utf8"),
+);
+const mainSource = readFileSync(new URL("../src/main.tsx", import.meta.url), "utf8");
+
+const mounted = [];
+let restoreCanvasContext = null;
+
+function renderUiLab({ route = "home", theme = "dark", language = "en" } = {}) {
+ activateDom();
+ if (!restoreCanvasContext) {
+ const getContext = dom.HTMLCanvasElement.prototype.getContext;
+ dom.HTMLCanvasElement.prototype.getContext = () => null;
+ restoreCanvasContext = () => {
+ dom.HTMLCanvasElement.prototype.getContext = getContext;
+ };
+ }
+ const view = mount(
+
+
+
+
+ ,
+ );
+ mounted.push(view);
+ return view;
+}
+
+afterEach(async () => {
+ for (const view of mounted.splice(0)) await reactAct(async () => view.unmount());
+ restoreCanvasContext?.();
+ restoreCanvasContext = null;
+ resetAppearanceSettings();
+ dom.window.localStorage.removeItem("codetwo.language");
+ dom.document.documentElement.classList.remove("dark", "scheme-mismatch");
+ dom.document.documentElement.lang = "en";
+ dom.document.body.replaceChildren();
+ await flush();
+});
+
+describe("UI Lab", () => {
+ test("publishes a stable catalog contract backed by the layout spec", () => {
+ const view = renderUiLab();
+
+ expect(view.container.querySelector("main h1")?.textContent).toBe("UI Lab");
+ expect(view.container.querySelector('nav[aria-label="UI Lab sections"] a[aria-current="page"]')?.textContent).toBe("Catalog");
+ expect(layoutSpec.content.uiLab).toEqual({
+ navigationWidth: 196,
+ catalogMaxWidth: 1160,
+ scenarioToolbarHeight: 48,
+ cardMinWidth: 280,
+ compactAt: 760,
+ behavior: "Keep a persistent catalog rail at standard widths, move it above content at compact widths, and let each production scenario retain its own container-query breakpoints.",
+ });
+
+ const links = Array.from(view.container.querySelectorAll("a")).map((link) => link.getAttribute("href"));
+ expect(links).toContain("?ui-lab=design-system&theme=dark&lang=en");
+ expect(links).toContain("?ui-lab=pull-requests&theme=dark&lang=en");
+ expect(links).toContain("?ui-lab=pr-dock&theme=dark&lang=en");
+ expect(links).toContain("?rich-transcript=1");
+ expect(links).toContain("?pet-preview=1");
+ expect(view.container.textContent).toContain("never call a bridge or remote service");
+ });
+
+ test("composes the production PR workspace with deterministic fixtures", async () => {
+ const view = renderUiLab({ route: "pull-requests" });
+
+ expect(view.container.querySelector(".ui-lab-scenario-toolbar")?.textContent).toContain("Deterministic fixture");
+ expect(view.container.querySelector(".pull-requests-list-pane h1")?.textContent).toBe("Pull requests");
+ expect(view.container.querySelector(".pull-requests-page")).not.toBeNull();
+
+ const summaries = await loadPullRequests();
+ const detail = await loadPullRequest(summaries[0]);
+ expect(summaries.map((item) => item.number)).toEqual([279, 276]);
+ expect(detail.files[0].path).toBe("apps/desktop/src/App.tsx");
+ expect(pullRequestTasks[0].pullRequest?.number).toBe(279);
+ });
+
+ test("composes the production PR review panel beside the conversation", async () => {
+ const view = renderUiLab({ route: "pr-dock" });
+
+ expect(view.container.querySelector("main[aria-label='Conversation fixture']")?.textContent).toContain("Usage sidebar review");
+ expect(view.container.querySelector("[data-dock-placement='right']")).not.toBeNull();
+ expect(view.container.querySelector('section[aria-label="GitHub pull request"]')).not.toBeNull();
+
+ const pullRequest = await pullRequestPanelApi.currentPullRequest("/ui-lab/acme/code-two");
+ const diff = await pullRequestPanelApi.pullRequestDiff("/ui-lab/acme/code-two", 279);
+ expect(pullRequest?.number).toBe(279);
+ expect(diff.text).toContain("const showUsage = account !== null;");
+ });
+
+ test("uses URL theme and language without overwriting normal preferences", async () => {
+ setAppearanceSettings({ preference: "light" });
+ dom.window.localStorage.setItem("codetwo.language", "en");
+ const appearanceBefore = dom.window.localStorage.getItem("codetwo.appearance.v1");
+
+ renderUiLab({ theme: "dark", language: "zh-CN" });
+ await flush();
+
+ expect(dom.document.documentElement.classList.contains("dark")).toBe(true);
+ expect(dom.document.documentElement.lang).toBe("zh-CN");
+ expect(dom.window.localStorage.getItem("codetwo.language")).toBe("en");
+ expect(dom.window.localStorage.getItem("codetwo.appearance.v1")).toBe(appearanceBefore);
+ });
+
+ test("keeps the UI Lab route development-only and preserves the legacy design-system route", () => {
+ expect(mainSource).toContain('import.meta.env.DEV ? searchParams.get("ui-lab") : null');
+ expect(mainSource).toContain('import.meta.env.DEV && searchParams.has("design-system")');
+ expect(mainSource).toContain('import("./design/ui-lab/UiLab")');
+ });
+});
diff --git a/docs/sdlc/changes/2026-09-01-floating-pr-inspector-prototype/change.md b/docs/sdlc/changes/2026-09-01-floating-pr-inspector-prototype/change.md
new file mode 100644
index 00000000..651dc791
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-01-floating-pr-inspector-prototype/change.md
@@ -0,0 +1,142 @@
+---
+id: change-2026-09-01-floating-pr-inspector-prototype
+kind: change
+schema: 2
+status: verified
+risk: low
+owner: codex
+approvers: user via the 2026-09-01 request to try the PR workspace's trailing Inspector as a floating panel
+approved_at: 2026-09-01
+created: 2026-09-01
+updated: 2026-09-01
+source: direct user visual-design feedback on the verified PR workspace and permanent UI Lab
+inputs: verified PR workspace, deterministic UI Lab PR fixture, existing Inspector breakpoints, and CodeTwo design tokens
+outputs: a development-only three-variant Inspector comparison with the floating card selected by default
+scope: apps/desktop/src/design/ui-lab, apps/desktop/layout-spec.json, apps/desktop/tests/uiLabRendered.test.tsx, docs/sdlc/changes/2026-09-01-floating-pr-inspector-prototype
+next_trigger: production promotion is tracked by change-2026-09-01-floating-pr-inspector
+verification_mode: owner
+verified_by: codex
+verified_at: 2026-09-01
+---
+
+# Prototype a floating PR Inspector
+
+## Intent
+
+The verified PR workspace currently attaches its contextual Inspector directly to the primary
+detail column. The user asked to try the trailing panel as a floating surface. The question is
+visual and spatial: whether detaching the Inspector improves hierarchy without making PR content
+feel obstructed or shrinking the primary review region too far.
+
+The desired outcome is a reversible UI Lab comparison using the real `PullRequestsPage` and its
+deterministic fixture. This change does not alter the production page, GitHub behavior, persisted
+state, or remote data. It should make the requested floating direction the default comparison
+while preserving the existing attached layout and a more aggressive overlay as references.
+
+## Spec
+
+The existing `?ui-lab=pull-requests` route accepts `variant=attached|floating|overlay` and defaults
+to `floating`. Attached retains the production rail. Floating reserves its existing bounded width
+but detaches it with a 12px inset, rounded surface, border, and elevation. Overlay gives the
+primary region its full width and positions the Inspector above it for comparison. The production
+960px Inspector collapse remains authoritative for all variants.
+
+A development-only bottom switcher shows the current variant, cycles with buttons or left/right
+arrow keys, writes selection into the URL, restores browser history, and does not intercept arrow
+keys while a text field is focused. Theme and locale links preserve the selected variant.
+
+### Acceptance criteria
+
+- [x] AC-1: PR Workspace defaults to a visibly detached floating Inspector while attached and
+ overlay variants remain reachable through stable URL state.
+- [x] AC-2: The switcher exposes accessible named controls, click and keyboard cycling, browser
+ history restoration, and preserves variant state across theme and locale links.
+- [x] AC-3: Desktop dark/light and narrow rendered checks show no unintended clipping, overflow,
+ framework overlay, or relevant console errors; the existing compact Inspector collapse is
+ unchanged.
+- [x] AC-4: Focused tests, renderer checks, documentation checks, SDLC checks, and whitespace
+ checks pass without adding the prototype to production application behavior.
+
+## Decision and gates
+
+The user's direct request accepts this low-risk, development-only visual prototype for execution.
+Codex owns implementation and owner verification. Selecting and promoting a production variant is
+a later human design Gate. The prototype request alone did not authorize GitHub mutation; the
+user's later `pr` request authorizes including this historical decision record in the verified
+Draft PR scope, but does not authorize merge, release, deployment, or production mutation.
+
+## Plan
+
+1. Record the three comparison geometries and keep the current production collapse breakpoint.
+2. Add URL-backed variant state and a development-only keyboard-accessible switcher to the
+ existing PR Workspace fixture.
+3. Style attached, floating, and overlay variants with current CodeTwo tokens and no production
+ component fork.
+4. Run focused checks and inspect desktop dark/light plus narrow behavior in the in-app browser.
+
+Rollback removes this change bundle and the UI Lab-only variant code. Production components,
+stored data, and remote services require no cleanup.
+
+## Build
+
+Added three URL-addressed Inspector layouts to the permanent development-only PR Workspace fixture.
+The requested floating card is the default and reserves the production Inspector column while
+adding a 12px inset, 16px radius, border, tokenized surface, and raised elevation. Attached remains
+the production baseline; overlay deliberately gives the primary region the full width for a more
+aggressive comparison.
+
+Added a bottom comparison switcher using the shared Button component. Clicks and left/right arrow
+keys update the visible variant and URL, browser history restores prior selection, text inputs keep
+their arrow-key behavior, and theme/locale navigation preserves the variant. The production
+component, GitHub bridge, and normal app routes are unchanged.
+
+## Verification
+
+Verdict: verified.
+
+### Acceptance evidence
+
+- AC-1: PASS — the in-app browser rendered `variant=attached`, `variant=floating`, and
+ `variant=overlay` at 1440x900. Floating was the no-param default and measured a 244px-wide card
+ inset 12px from the top, right, and bottom with a 16px radius and raised shadow.
+- AC-2: PASS — browser flow `Next -> Back -> ArrowLeft` cycled C to B to A
+ and restored B with the matching URL; theme navigation retained `variant=floating`. The focused
+ `bun test tests/uiLabRendered.test.tsx` contract also passed six tests and 32 expectations.
+- AC-3: PASS — browser viewport checks `1440x900 dark`, `1440x900 light`, and `680x860 light`
+ rendered floating in both themes and the compact state. The narrow state hid the Inspector under
+ the existing 960px rule. Every state kept body
+ width equal to viewport width, contained meaningful DOM, showed no framework overlay, and had no
+ console warnings or errors.
+- AC-4: PASS — full `cd apps/desktop && bun test` passed 800 tests across 138 files with 3834
+ expectations. `bun run build:renderer` passed ESLint, Stylelint, TypeScript, and Vite; exact
+ prototype strings were absent from production output. Final docs, SDLC, worktree, and whitespace
+ Gates passed.
+
+The first verification attempt failed because the switcher used raw buttons, one shadow token did
+not exist, and a full-suite assertion read a shared test window's URL. The correction adopted the
+shared Button, the existing menu elevation token, and component-owned link state while retaining
+real-browser URL proof; the complete rerun then passed.
+
+Residual risk: this verifies a renderer-only deterministic fixture, not native WebView chrome or a
+real authenticated GitHub session. The production PR Inspector remains attached until the user
+selects a prototype variant.
+
+## Review and release
+
+Review handoff: [Draft PR #212](https://github.com/IchenDEV/codeTwo/pull/212).
+Approval: comparison implementation, subsequent production selection, and Draft PR delivery were
+authorized by the user's 2026-09-01 requests.
+Release target: none.
+Release identity: not applicable until released.
+Smoke evidence: not applicable until released.
+Rollback: remove the UI Lab-only prototype wrapper, styles, switcher, layout contract, tests, and this Artifact.
+No release: the selected comparison is retained as historical decision evidence in the authorized
+Draft PR; merge, deployment, and release remain unauthorized.
+
+Preparing this section does not authorize merge, deployment, release, or production mutation.
+
+## Feedback
+
+The user selected the reserved floating-card variant and requested production implementation on
+2026-09-01. Promotion and prototype cleanup are tracked in
+[`change-2026-09-01-floating-pr-inspector`](../2026-09-01-floating-pr-inspector/change.md).
diff --git a/docs/sdlc/changes/2026-09-01-floating-pr-inspector/change.md b/docs/sdlc/changes/2026-09-01-floating-pr-inspector/change.md
new file mode 100644
index 00000000..b4954642
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-01-floating-pr-inspector/change.md
@@ -0,0 +1,131 @@
+---
+id: change-2026-09-01-floating-pr-inspector
+kind: change
+schema: 2
+status: verified
+risk: low
+owner: codex
+approvers: user via the 2026-09-01 request to start implementing the approved floating Inspector
+approved_at: 2026-09-01
+created: 2026-09-01
+updated: 2026-09-01
+source: direct implementation request after reviewing change-2026-09-01-floating-pr-inspector-prototype
+inputs: selected reserved floating-card prototype, production PullRequestsPage, existing Inspector layout contract, and permanent UI Lab fixture
+outputs: the production PR Inspector as a reserved floating card with obsolete prototype variants removed
+scope: apps/desktop/src/github/PullRequestsPage.tsx, apps/desktop/src/github/pull-requests.css, apps/desktop/src/design/ui-lab, apps/desktop/layout-spec.json, apps/desktop/tests/githubPullRequestsRendered.test.tsx, apps/desktop/tests/uiLabRendered.test.tsx, docs/sdlc/changes/2026-09-01-floating-pr-inspector, docs/sdlc/changes/2026-09-01-floating-pr-inspector-prototype
+next_trigger: human review of the authorized Draft PR
+verification_mode: owner
+verified_by: codex owner verification
+verified_at: 2026-09-01
+---
+
+# Promote the floating PR Inspector
+
+## Intent
+
+The UI Lab comparison established that a reserved floating card gives the PR Inspector clearer
+hierarchy without obscuring review content. The user selected that direction and asked to begin
+implementation. The production PR Workspace should now use the chosen floating treatment, while
+the permanent UI Lab should return to rendering a single truthful production state instead of
+retaining obsolete prototype controls.
+
+This change is visual only. It must preserve Inspector content, semantics, width bounds, scrolling,
+the 960px collapse rule, list/detail compact behavior, GitHub operations, and persisted state. It
+must not keep A/C variants, add a presentation setting, change the conversation-side PR Dock, or
+introduce another component abstraction.
+
+## Spec
+
+At widths above 960px, the trailing Inspector remains in its existing reserved grid column and is
+inset 12px from the top, right, and bottom. It uses the established surface, border, modal radius,
+and raised-elevation tokens. The leading edge remains aligned with its reserved column so primary
+content width and existing hierarchy match the approved prototype. At or below 960px the Inspector
+continues to hide before the list/detail compact transition.
+
+The UI Lab PR Workspace renders this production behavior directly. Prototype query-state parsing,
+the A/B/C switcher, overlay styles, and prototype-specific tests/layout metadata are removed.
+
+### Acceptance criteria
+
+- [x] AC-1: The production Pull requests page renders its trailing Inspector as the selected
+ reserved floating card without changing its content, semantics, width bounds, or actions.
+- [x] AC-2: At or below 960px the Inspector still hides before the existing 704px list/detail
+ transition, with no horizontal overflow in desktop or compact UI Lab rendering.
+- [x] AC-3: The permanent UI Lab renders the production PR Workspace without variant query state,
+ prototype switcher UI, or prototype-only CSS and layout contracts.
+- [x] AC-4: Focused tests, full desktop tests, renderer build, documentation and SDLC Gates, and
+ rendered dark/light/narrow inspection pass with no relevant console errors.
+
+## Decision and gates
+
+The user's direct request after reviewing the prototype accepts Intent, Spec, and the visual design
+Gate for production implementation. Codex owns implementation and owner verification. The user's
+later `pr` request authorizes creating a branch, pushing this verified scope, and opening a Draft
+PR. Merge, release, deployment, and production-environment mutation remain unauthorized.
+
+## Plan
+
+1. Move the selected inset, surface, border, radius, and elevation to the production Inspector CSS.
+2. Record those stable constraints under the production Pull requests layout contract and protect
+ them with the existing rendered test.
+3. Delete the A/C variants, URL state, switcher, and prototype-only styling/test metadata from UI
+ Lab while retaining the verified prototype Artifact as the decision record.
+4. Run focused checks, inspect production-component rendering in dark/light and compact widths,
+ then run the repository handoff Gates.
+
+Rollback restores the attached Inspector classes/CSS and the earlier layout contract. No data,
+backend, GitHub, migration, or remote cleanup is required.
+
+## Build
+
+The production `PullRequestsPage` Inspector keeps its existing grid column and semantics, while
+`pull-requests.css` now supplies the selected 12px inset, surface border, modal radius, and raised
+elevation. UI Lab continues to render `PullRequestsPage` itself, but its temporary variant query
+state, switcher, A/C presentation styles, and prototype-only layout/test contracts are deleted.
+
+The production layout contract records the stable floating-card geometry and preserves the 960px
+Inspector collapse ahead of the existing 704px list/detail compact transition. No GitHub data,
+operation, persistence, Dock, or conversation-side behavior changed.
+
+## Verification
+
+Verdict: verified.
+
+### Acceptance evidence
+
+- AC-1: PASS — `cd apps/desktop && bun test tests/githubPullRequestsRendered.test.tsx tests/uiLabRendered.test.tsx` completed with 10 passing tests and 85 expectations; live dark and light UI Lab inspection measured the production Inspector at a 12px top/right/bottom inset, 16px radius, token-derived shadow, intact `complementary` semantics, and working Review changes navigation.
+- AC-2: PASS — `cd apps/desktop && bun test tests/githubPullRequestsRendered.test.tsx tests/uiLabRendered.test.tsx` protected the 960px/704px layout contract; live checks at 1280px, 920px, and 680px found no horizontal overflow, hid only the Inspector at 920px, and preserved the existing list-only compact state at 680px.
+- AC-3: PASS — `rg -n "ui-lab-prototype-switcher|data-inspector-variant|PullRequestInspectorVariant|variant=overlay|variant=attached" apps/desktop/src apps/desktop/tests apps/desktop/layout-spec.json` returned no matches, while dark/light rendering exposed no prototype controls or variant query state.
+- AC-4: PASS — `cd apps/desktop && bun test` completed with 799 passing tests, 0 failures, and 3,847 expectations; `bun run lint` and `bun run build:renderer` passed; `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, `bun script/verify/sdlc.ts --worktree`, and `git diff --check` passed. Dark, light, medium, and narrow rendered inspection produced no relevant console warning or error.
+
+Lock-screen-compatible native acceptance additionally launched the current packaged `C2-dev.app`
+against a fresh isolated data directory. The native host stayed live, bound its expected local port,
+and registered a 1176x784 `C2 Dev` main window with the macOS window server. Because macOS blocks
+window capture while the desktop is locked, a same-host native `WKWebView` harness loaded the
+permanent production-component fixture and used WebKit's own snapshot path. The harness advanced
+only the decorative entrance animation to its normal completed state because hidden documents do
+not tick that animation. At 1280x720 it verified and captured dark and light rendering with the
+12px right inset, 16px radius, token-derived shadow, no overflow, and no error state; clicking
+Review changes selected Changes and rendered the 30-file view. At 920x800 it verified and captured
+the expected hidden Inspector with both list and detail panes remaining visible and no overflow.
+
+Residual risk: the locked-machine pass separates native-shell startup from native-WebKit visual
+and interaction rendering; it does not provide a pixel capture of the actual Electrobun window.
+A signed-in native-shell GitHub session was also not exercised, so provider/auth integration remains
+covered by existing automated behavior rather than this visual pass.
+
+## Review and release
+
+Review handoff: [Draft PR #212](https://github.com/IchenDEV/codeTwo/pull/212).
+Approval: implementation plus Draft PR delivery from the user's 2026-09-01 requests.
+Release target: none.
+Release identity: not applicable until released.
+Smoke evidence: not applicable until released.
+Rollback: revert this production floating-card change; no migration or remote cleanup is required.
+No release: Draft PR delivery is authorized; merge and release are not authorized.
+
+Preparing this section does not authorize merge, deployment, release, or production mutation.
+
+## Feedback
+
+No post-implementation feedback exists yet.
diff --git a/docs/sdlc/changes/2026-09-01-pr-workspace-and-dock/change.md b/docs/sdlc/changes/2026-09-01-pr-workspace-and-dock/change.md
new file mode 100644
index 00000000..7ca55a73
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-01-pr-workspace-and-dock/change.md
@@ -0,0 +1,180 @@
+---
+id: change-2026-09-01-pr-workspace-and-dock
+kind: change
+schema: 2
+status: verified
+risk: medium
+owner: codex
+approvers: user via the 2026-09-01 PR workspace implementation approval
+approved_at: 2026-09-01
+created: 2026-09-01
+updated: 2026-09-01
+source: direct user request with an attached PR-workspace reference, followed by approval of the rendered CodeTwo prototype and an explicit request to implement it
+inputs: existing Pull requests page, current-branch GitHub pull-request panel, right work Dock, CodeTwo layout specification, and the approved rendered prototype
+outputs: a three-region Pull requests workbench plus a dedicated current-branch PR surface beside the conversation
+scope: apps/desktop/src/github, apps/desktop/src/git, apps/desktop/src/dock/Dock.tsx, apps/desktop/src/App.tsx, apps/desktop/src/i18n/strings.ts, apps/desktop/layout-spec.json, apps/desktop/tests, docs/sdlc/changes/2026-09-01-pr-workspace-and-dock
+next_trigger: human review of the authorized Draft PR
+verification_mode: owner
+verified_by: codex
+verified_at: 2026-09-01
+---
+
+# Improve the PR workspace and add a conversation-side PR surface
+
+## Intent
+
+The user wants CodeTwo's GitHub pull-request experience to carry the information hierarchy of the
+supplied reference without copying its application-wide navigation. The full Pull requests page
+currently has a list and detail view, but branch, review, checks, status, and task metadata compete
+inside one central column. The right work Dock already contains a capable current-branch PR panel,
+but it is hidden inside the broader Git surface and therefore is not a direct conversation-side PR
+destination.
+
+The desired outcome is a macOS-oriented split workspace: global PR selection on the leading side,
+the selected PR's title and content in the primary region, and contextual merge/review/check/task
+state in a trailing Inspector. Beside a coding conversation, PR must be a first-class Dock surface
+that follows the focused session's checkout and branch. This change must reuse the existing GitHub
+bridge, review/merge behavior, design tokens, workbench breakpoints, and Dock ownership. It must not
+add another GitHub protocol, change merge authorization, create or mutate pull requests, redesign
+the application rail, or introduce a mobile application layout.
+
+## Spec
+
+At standard usable widths, Pull requests displays three regions: the existing filtered PR list, a
+primary detail region, and a trailing Inspector. The primary header keeps title, author, state,
+source and base branches, changed-file count, and additions/deletions together. Summary, Changes,
+and Checks are explicit detail tabs. Review changes opens Changes; Join conversation uses the
+existing chat handoff. The Inspector prioritizes merge readiness, review state, reviewers, task
+association, checks, comments, and last activity without duplicating a second PR description.
+
+The workbench keeps the existing `704px` list/detail collapse contract. The new Inspector is
+`clamp(192px, 23cqw, 256px)` and collapses first when the Pull requests container is at or below
+`960px`, preserving a usable primary region. Status meaning always includes text or an icon, not
+color alone. Existing loading, empty, error, filtering, task-linking, GitHub-open, and compact-back
+behavior remains available.
+
+The right work Dock exposes a dedicated PR tab beside Files and Git. It renders the existing
+current-branch `GitHubPullRequestPanel`, including overview, diff, checks, review submission, merge
+confirmation, and GitHub opening. Git remains the working-tree summary and no longer embeds a
+second copy of the PR panel. PR availability follows the existing `git.surface` component policy;
+missing GitHub remotes, `gh`, authentication, repository state, or branch PR data continue to use
+the existing safe empty and error states.
+
+### Acceptance criteria
+
+- [x] AC-1: At standard width the full Pull requests page renders the selected PR as list, primary
+ detail, and contextual Inspector; at or below the recorded breakpoints it removes the
+ Inspector first and preserves the existing list/detail compact navigation.
+- [x] AC-2: The primary detail exposes Summary, Changes, and Checks with title/state/branch/diff
+ context, while Review changes opens Changes and Join conversation keeps the selected PR.
+- [x] AC-3: The conversation-side Dock has a direct PR surface for the focused checkout and branch;
+ Git shows only working-tree state, and the existing review, merge, diff, loading, empty, and
+ failure behavior remains unchanged.
+- [x] AC-4: English and Chinese labels, keyboard-accessible controls, semantic landmarks,
+ non-color-only states, and narrow reflow are covered by focused rendered tests.
+- [x] AC-5: Focused tests, full desktop tests, renderer type/build/lint checks, lifecycle Gates, and
+ real rendered dark, light, and narrow inspection pass with no relevant clipping or console
+ errors.
+
+## Decision and gates
+
+The user approved the rendered direction and explicitly requested implementation on 2026-09-01,
+which accepts Intent, Spec, and the visual design Gate for execution. Codex owns implementation and
+owner verification. No security, data migration, provider protocol, merge, release, deployment, or
+production Gate is opened. The user's separate `pr` request on 2026-09-01 authorizes creating a
+branch, pushing this verified scope, and opening a Draft PR; it does not authorize merging,
+releasing, or deploying CodeTwo.
+
+## Plan
+
+1. Record the new Pull requests Inspector geometry in the existing desktop layout specification.
+2. Promote the existing current-branch PR panel into a dedicated Dock surface and leave Git as the
+ working-tree surface without adding backend commands or data models.
+3. Recompose the full Pull requests detail into a primary region plus Inspector, add Checks as an
+ explicit view, and preserve compact selection, task linkage, and chat handoff.
+4. Update English and Chinese copy and protect the new Dock ownership, state projection, detail
+ views, action behavior, semantics, and responsive contract with focused tests.
+5. Run the applicable full Gates and inspect real renderer output in dark, light, standard, and
+ narrow layouts before changing the Artifact to `verified`.
+
+Rollback reverts this change. GitHub commands and persisted data are unchanged, so rollback does
+not require migration or remote cleanup.
+
+## Build
+
+- Added `pull-request` as a first-class Dock surface under the existing `git.surface` component
+ policy. It renders the existing current-branch `GitHubPullRequestPanel`; the Git surface now
+ owns only the working-tree summary.
+- Reworked the full Pull requests detail into a primary content region plus semantic Inspector,
+ with Summary, Changes, and Checks views, a primary Review changes action, and the existing chat,
+ GitHub-open, task-link, filtering, loading, error, and compact-back behavior preserved.
+- Added a deterministic merge-readiness projection and shared check-result projection so text,
+ icons, tones, and counts agree across Summary, Checks, and Inspector states.
+- Added English and Chinese labels, layout-spec geometry, responsive action-label behavior, and
+ focused projection, interaction, Dock-ownership, semantic, and reflow coverage.
+- No GitHub command, persistence, review/merge authorization, dependency, or remote data contract
+ changed. The temporary development-only visual fixture used for browser screenshots was removed
+ before final build and verification.
+
+## Verification
+
+Verdict: verified.
+
+Real renderer inspection used the in-app browser against the Vite development renderer at
+`http://127.0.0.1:1420/`. A temporary local fixture mounted the production components and was
+removed before the final build. The standard 1440x900 dark workspace rendered the PR list,
+primary Summary, and 256px Inspector without clipping. Review changes selected Changes and showed
+the file list; Checks showed three named passed checks. The 1440x900 light Chinese rendering
+showed translated navigation, actions, and Inspector labels. At 680x820 the Inspector and list
+collapsed, the back action and primary Review changes label remained visible, and the page
+reported `clientWidth == scrollWidth == 680`. The conversation preview rendered the real Dock and
+current-branch PR panel at 440px; PR was directly selectable, all checks were visible, and Changes
+rendered the diff. All inspected states had no relevant browser warnings or errors.
+
+### Acceptance evidence
+
+- AC-1: PASS — `layout-spec.json` records `clamp(192px, 23cqw, 256px)` and the 960px-first /
+ 704px-second collapse sequence; rendered 1440x900 and 680x820 inspection confirmed the three
+ regions and compact detail with no horizontal overflow.
+- AC-2: PASS — `cd apps/desktop && bun test tests/githubPullRequests.test.ts
+ tests/githubPullRequestsRendered.test.tsx tests/githubPullRequestPanelRendered.test.tsx` exercised
+ Summary, Changes, Checks, Review changes, and Join conversation; real browser interaction
+ confirmed the changed-file and check rows.
+- AC-3: PASS — focused Dock and existing `GitHubPullRequestPanel` tests passed, including overview,
+ diff, empty, draft, review, merge-confirmation, and Git-only ownership checks; the rendered Dock
+ showed the active PR surface beside a conversation.
+- AC-4: PASS — `cd apps/desktop && bun test tests/githubPullRequests.test.ts
+ tests/githubPullRequestsRendered.test.tsx tests/githubPullRequestPanelRendered.test.tsx
+ tests/dockArchitecture.test.ts tests/dockPluginGateRendered.test.tsx` passed 22 tests and 161
+ expectations; English and Chinese rendered labels, semantic Inspector, accessible action names,
+ text-plus-icon status, and responsive CSS contracts are covered. Final `bun test` passed 794
+ tests across 137 files, 3814 expectations, and zero failures.
+- AC-5: PASS — `bun run build:renderer` completed ESLint, Stylelint, TypeScript, and Vite build;
+ `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`,
+ `bun script/verify/sdlc.ts --worktree`, and `git diff --check` all passed. Browser screenshots
+ were captured to `/tmp/codetwo-pr-workspace-dark.png`,
+ `/tmp/codetwo-pr-workspace-light-zh.png`, `/tmp/codetwo-pr-workspace-narrow.png`, and
+ `/tmp/codetwo-pr-dock-dark.png`.
+
+Residual risk: live authenticated GitHub review, comment, and merge mutations were not performed
+because this request did not authorize remote mutations. Their existing panel workflow and mocked
+regression coverage pass unchanged. Merge readiness is an intentional local projection of the
+detail fields already returned by GitHub; it does not claim to replace GitHub's authoritative
+merge button decision.
+
+## Review and release
+
+Review handoff: [Draft PR #212](https://github.com/IchenDEV/codeTwo/pull/212).
+Approval: implementation plus Draft PR delivery, from the user's 2026-09-01 requests.
+Release target: none.
+Release identity: not applicable until released.
+Smoke evidence: not applicable until released.
+Rollback: revert this change; there is no data or remote migration.
+No release: implementation, push, and Draft PR creation are authorized; merge, deployment, and
+release are not authorized.
+
+Preparing this section does not authorize merge, deployment, release, or production mutation.
+
+## Feedback
+
+No post-implementation feedback exists yet.
diff --git a/docs/sdlc/changes/2026-09-01-ui-lab/change.md b/docs/sdlc/changes/2026-09-01-ui-lab/change.md
new file mode 100644
index 00000000..707ea9be
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-01-ui-lab/change.md
@@ -0,0 +1,167 @@
+---
+id: change-2026-09-01-ui-lab
+kind: change
+schema: 2
+status: verified
+risk: low
+owner: codex
+approvers: user via the 2026-09-01 request to make UI test and design-system demo pages permanent
+approved_at: 2026-09-01
+created: 2026-09-01
+updated: 2026-09-01
+source: direct user request after clarifying that the earlier PR screenshots came from a temporary fixture page
+inputs: existing DesignSystemPreview, development preview query routes, production UI components, and deterministic PR test fixtures
+outputs: a permanent development-only UI Lab catalog with stable design-system and product-scenario URLs
+scope: apps/desktop/src/main.tsx, apps/desktop/src/theme.tsx, apps/desktop/src/i18n/index.tsx, apps/desktop/src/design, apps/desktop/layout-spec.json, apps/desktop/tests, docs/sdlc/changes/2026-09-01-ui-lab
+next_trigger: human review of the authorized Draft PR, then future UI Lab scenario additions
+verification_mode: owner
+verified_by: codex
+verified_at: 2026-09-01
+---
+
+# Add a permanent UI Lab and design-system demo catalog
+
+## Intent
+
+The repository has a substantial `?design-system` preview and several one-off development query
+routes, but no single discoverable catalog for stable UI fixtures. During PR-workspace validation a
+temporary page was useful for rendering real components with deterministic data, yet the resulting
+screenshot could be mistaken for the actual desktop application. The user asked to make a fixed
+set of UI test and design-system demo pages available for future design and regression work.
+
+The desired outcome is a development-only UI Lab with canonical URLs, an explicit fixture identity,
+and real production components driven by deterministic local data. It must reuse the existing
+design system and previews, avoid a second visual language, avoid remote GitHub actions, and stay
+out of normal production application behavior. It is a developer surface, not evidence that an
+authenticated real-app workflow passed.
+
+## Spec
+
+`?ui-lab=home` is the catalog. It links to `?ui-lab=design-system`,
+`?ui-lab=pull-requests`, and `?ui-lab=pr-dock`, and also exposes the existing rich-transcript and
+pet previews. The existing `?design-system` URL remains a backwards-compatible alias. Product
+scenario pages use a shared lab toolbar with a back link, route identity, a visible
+`Deterministic fixture` marker, theme links, and locale links. URL query state is sufficient for
+direct reload and browser back/forward behavior.
+
+The PR workspace scenario renders the real `PullRequestsPage` with stable list, detail, file,
+check, reviewer, label, and task data. The PR Dock scenario renders the real Dock and
+`GitHubPullRequestPanel` beside a restrained conversation fixture, including Overview and Changes.
+Neither scenario calls the desktop bridge or mutates GitHub. Light/dark and English/Chinese
+overrides are controlled by URL and do not persist into the user's normal application settings.
+
+The catalog and scenario shell use the established 196px design-preview navigation width, 48px
+toolbar, 1160px content bound, design tokens, and 760px compact breakpoint. At compact widths the
+catalog navigation becomes a top bar and scenario content retains its own product breakpoints.
+All navigation is semantic, keyboard reachable, visibly focused, and exposes current-page state.
+
+### Acceptance criteria
+
+- [x] AC-1: The development renderer exposes a discoverable UI Lab catalog with stable links to
+ Design System, PR Workspace, PR Dock, rich transcript, and pet preview; the legacy
+ `?design-system` route remains functional.
+- [x] AC-2: PR Workspace and PR Dock render production components with deterministic local data,
+ make fixture/dev-only identity unmistakable, and perform no bridge or remote mutation.
+- [x] AC-3: `theme=system|light|dark` and `lang=en|zh` are URL-addressable for UI Lab routes and do
+ not overwrite persisted normal-app preferences.
+- [x] AC-4: Semantic navigation, current-page state, keyboard names/focus, light/dark rendering,
+ and desktop/narrow reflow are protected by focused tests and real browser evidence without
+ horizontal overflow or relevant console errors.
+- [x] AC-5: Full desktop tests, renderer lint/type/build, docs and SDLC Gates, and production-build
+ checks pass; normal application startup remains the fallback when no development preview
+ query is present.
+
+## Decision and gates
+
+The user's direct implementation request on 2026-09-01 accepts Intent and this narrowly scoped
+developer-tool design. Codex owns implementation and owner verification. The user's later `pr`
+request authorizes creating a branch, pushing this verified scope, and opening a Draft PR. Merge,
+release, deployment, and production mutation remain unauthorized.
+
+## Plan
+
+1. Record the fixed UI Lab shell and breakpoint contract in the existing layout specification.
+2. Add a development-only query router and shared catalog/scenario shell under `src/design`.
+3. Move deterministic PR workspace and Dock fixtures into permanent, explicitly labelled
+ scenarios that render the existing production components.
+4. Add non-persistent theme and locale provider overrides for URL-driven preview states while
+ preserving existing provider behavior everywhere else.
+5. Add route and rendered coverage, run full Gates, and inspect standard and narrow views in the
+ in-app browser before marking this Artifact verified.
+
+Rollback removes the UI Lab route/files and optional provider overrides. There is no stored data,
+backend command, dependency, or remote cleanup.
+
+## Build
+
+- Added a development-only `?ui-lab=` router and catalog with canonical Design System, PR
+ Workspace, and conversation-side PR Dock routes plus links to the existing rich-transcript and
+ pet previews. The normal application remains the fallback, and the legacy `?design-system`
+ alias remains available.
+- Added a shared scenario toolbar, explicit `Dev only` and `Deterministic fixture` labels, stable
+ layout-spec geometry, semantic catalog navigation, and responsive catalog/scenario shells using
+ the existing C2 design tokens.
+- Added deterministic PR data and API adapters that render the production `PullRequestsPage`,
+ `Dock`, and `GitHubPullRequestPanel` without invoking the desktop bridge, network, or GitHub
+ mutations.
+- Added non-persistent theme and locale provider overrides. UI Lab URLs can select system, light,
+ dark, English, or Chinese without rewriting the user's saved normal-app settings.
+- Made the Design System preview inherit the selected URL theme and keep a visible return to UI
+ Lab at both desktop and compact widths.
+
+## Verification
+
+Verdict: verified.
+
+The in-app browser inspected the development renderer at `http://127.0.0.1:1420/`. At 1440x900,
+the dark catalog, light PR Workspace, and dark conversation-side PR Dock rendered without clipping
+or horizontal overflow. Theme navigation changed the URL and removed the dark root state. Review
+changes selected the production Changes tab and revealed the fixture file list; Dock Changes 30
+rendered the expected added and removed diff lines. At 680x860, PR Workspace switched from list to
+detail, PR Dock became a full-width panel, and Design System kept a visible UI Lab return link.
+Every inspected state reported `body.scrollWidth == window.innerWidth` and no relevant browser
+warnings or errors.
+
+### Acceptance evidence
+
+- AC-1: PASS — `tests/uiLabRendered.test.tsx` protects the catalog's five stable destinations,
+ current-page semantics, layout contract, development gate, and legacy Design System alias;
+ browser navigation loaded each canonical page with title `C2` and meaningful DOM content.
+- AC-2: PASS — `cd apps/desktop && bun test tests/uiLabRendered.test.tsx` protects the production
+ workspace/Dock composition and deterministic PR, task, current-branch, and diff fixtures.
+ Browser interaction confirmed the resolved fixture data and production Changes views; the
+ production Vite output contains none of the UI Lab or fixture strings.
+- AC-3: PASS — `cd apps/desktop && bun test tests/uiLabRendered.test.tsx` confirmed dark/Chinese
+ preview state while the saved light/English preferences remained byte-for-byte unchanged.
+ Browser theme navigation confirmed URL-addressed light and dark states; Design System inherited
+ the URL theme.
+- AC-4: PASS — `cd apps/desktop && bun test tests/uiLabRendered.test.tsx
+ tests/githubPullRequestsRendered.test.tsx tests/githubPullRequestPanelRendered.test.tsx`
+ protects the semantic and interaction contracts. Browser DOM snapshots confirmed semantic
+ landmarks and controls; screenshots covered dark/light at 1440x900 and compact PR, Dock, and
+ Design System at 680x860 with no horizontal overflow or console errors.
+- AC-5: PASS — full `bun test` passed 799 tests across 138 files with 3829 expectations and zero
+ failures. `bun run build:renderer` completed ESLint, Stylelint, TypeScript, and Vite build. Final
+ docs, SDLC, worktree, and whitespace Gates passed.
+
+Residual risk: UI Lab is intentionally a renderer-only developer surface, so it does not validate
+native WebView chrome or authenticated GitHub mutations. Those remote actions remain disabled by
+the fixture API and covered by the existing production panel tests rather than executed against a
+real repository.
+
+## Review and release
+
+Review handoff: [Draft PR #212](https://github.com/IchenDEV/codeTwo/pull/212).
+Approval: implementation plus Draft PR delivery from the user's 2026-09-01 requests.
+Release target: none.
+Release identity: not applicable until released.
+Smoke evidence: not applicable until released.
+Rollback: revert this change; no migration or remote cleanup is required.
+No release: this remains a development surface; Draft PR creation is authorized, while merge,
+deployment, and release are not authorized.
+
+Preparing this section does not authorize merge, deployment, release, or production mutation.
+
+## Feedback
+
+No post-implementation feedback exists yet.