First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
@@ -0,0 +1,560 @@
"use client";
import { Check, Landmark, Lock, RefreshCw, Wallet } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import {
type OfficeUsageAnalyticsParams,
useOfficeUsageAnalyticsViewModel,
} from "@/features/office/hooks/useOfficeUsageAnalyticsViewModel";
import {
formatCurrency,
formatNumber,
toDateInputValue,
} from "@/lib/office/usageAnalyticsPresentation";
const PIN_STORAGE_KEY = "openclaw_atm_pin_code";
const resolveInitialPinMode = (): "setup" | "verify" => {
if (typeof window === "undefined") {
return "verify";
}
return window.localStorage.getItem(PIN_STORAGE_KEY) ? "verify" : "setup";
};
export function AtmImmersiveScreen(props: OfficeUsageAnalyticsParams) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [pinMode] = useState<"setup" | "verify">(resolveInitialPinMode);
const [inputPin, setInputPin] = useState("");
const [error, setError] = useState<string | null>(null);
const { usage, settingsLoaded, startDate, endDate, setStartDate, setEndDate } =
useOfficeUsageAnalyticsViewModel(props);
const handlePinSubmit = () => {
if (inputPin.length < 4) {
setError("PIN must be at least 4 digits");
return;
}
if (pinMode === "setup") {
localStorage.setItem(PIN_STORAGE_KEY, inputPin);
setIsAuthenticated(true);
setError(null);
} else {
const stored = localStorage.getItem(PIN_STORAGE_KEY);
if (inputPin === stored) {
setIsAuthenticated(true);
setError(null);
} else {
setError("Incorrect PIN");
setInputPin("");
}
}
};
const handleKeyPad = (key: string) => {
setError(null);
if (key === "clear") {
setInputPin("");
} else if (key === "backspace") {
setInputPin((prev) => prev.slice(0, -1));
} else if (key === "submit") {
handlePinSubmit();
} else {
if (inputPin.length < 6) {
setInputPin((prev) => prev + key);
}
}
};
const recentCostDaily = useMemo(() => usage.costDaily.slice(-7), [usage.costDaily]);
const chartMax = useMemo(
() => recentCostDaily.reduce((max, entry) => Math.max(max, entry.totalCost), 0),
[recentCostDaily],
);
const overviewCards = useMemo(
() => [
{ label: "Total Spend", value: formatCurrency(usage.totals.totalCost) },
{ label: "Total Tokens", value: formatNumber(usage.totals.totalTokens) },
{ label: "Sessions", value: formatNumber(usage.sessions.length) },
{ label: "Messages", value: formatNumber(usage.aggregates.messages.total) },
{ label: "Tool Calls", value: formatNumber(usage.aggregates.tools.totalCalls) },
{ label: "Unique Tools", value: formatNumber(usage.aggregates.tools.uniqueTools) },
{ label: "Errors", value: formatNumber(usage.aggregates.messages.errors) },
{
label: "Avg Session Cost",
value:
usage.sessions.length > 0
? formatCurrency(usage.totals.totalCost / usage.sessions.length)
: formatCurrency(0),
},
],
[usage],
);
const recentSessions = useMemo(
() =>
[...usage.sessions]
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
.slice(0, 18),
[usage.sessions],
);
const selectedRangeLabel = useMemo(() => {
const now = new Date();
const end = toDateInputValue(now);
const lastWeek = new Date(now);
lastWeek.setDate(lastWeek.getDate() - 6);
const lastMonth = new Date(now);
lastMonth.setDate(lastMonth.getDate() - 29);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
if (startDate === toDateInputValue(lastWeek) && endDate === end) return "7D";
if (startDate === toDateInputValue(lastMonth) && endDate === end) return "30D";
if (startDate === toDateInputValue(monthStart) && endDate === end) return "MTD";
return "Custom";
}, [endDate, startDate]);
const setQuickRange = (days: number | "mtd") => {
const end = new Date();
const start = new Date(end);
if (days === "mtd") {
start.setDate(1);
} else {
start.setDate(start.getDate() - (days - 1));
}
setStartDate(toDateInputValue(start));
setEndDate(toDateInputValue(end));
};
if (!isAuthenticated) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[radial-gradient(circle_at_center,#113a3d_0%,#071719_65%,#020607_100%)] text-[#d6fff7]">
<div className="pointer-events-none absolute inset-0 opacity-20 [background-image:linear-gradient(rgba(130,255,228,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(130,255,228,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="relative z-10 flex flex-col items-center">
<div className="mb-8 flex h-20 w-20 items-center justify-center rounded-full border border-[#7dfff0]/30 bg-[#0d3034] shadow-[0_0_40px_rgba(125,255,240,0.15)]">
<Lock className="h-8 w-8 text-[#7dfff0]" />
</div>
<h2 className="text-[24px] font-medium tracking-[0.1em] text-[#dbfff6]">
{pinMode === "setup" ? "CREATE ACCESS PIN" : "ENTER PIN CODE"}
</h2>
<p className="mt-2 text-[13px] uppercase tracking-[0.15em] text-[#83fff0]/60">
{pinMode === "setup"
? "Set a secure code for your treasury ledger"
: "Authentication required to view ledger"}
</p>
<div className="mb-8 mt-10 flex gap-4">
{[...Array(4)].map((_, i) => (
<div
key={i}
className={`h-4 w-4 rounded-full border border-[#7dfff0]/40 transition-all duration-200 ${
i < inputPin.length
? "bg-[#7dfff0] shadow-[0_0_15px_rgba(125,255,240,0.6)]"
: "bg-transparent"
}`}
/>
))}
</div>
{error ? (
<div className="animate-in slide-in-from-top-2 mb-6 rounded-lg border border-rose-500/20 bg-rose-500/10 px-4 py-2 text-[13px] font-medium text-rose-200 fade-in">
{error}
</div>
) : null}
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
<button
key={num}
onClick={() => handleKeyPad(num.toString())}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-[#7dfff0]/10 bg-[#041315]/80 text-[24px] font-light text-[#d6fff7] transition-all hover:bg-[#0d3034] hover:border-[#7dfff0]/30 active:scale-95"
>
{num}
</button>
))}
<button
onClick={() => handleKeyPad("clear")}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-rose-500/20 bg-[#1a0505]/60 text-[14px] font-medium uppercase tracking-wider text-rose-200 transition-all hover:bg-rose-900/40 active:scale-95"
>
Clear
</button>
<button
onClick={() => handleKeyPad("0")}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-[#7dfff0]/10 bg-[#041315]/80 text-[24px] font-light text-[#d6fff7] transition-all hover:bg-[#0d3034] hover:border-[#7dfff0]/30 active:scale-95"
>
0
</button>
<button
onClick={() => handleKeyPad("submit")}
className="flex h-16 w-24 items-center justify-center rounded-xl border border-emerald-500/20 bg-[#051a10]/60 text-emerald-200 transition-all hover:bg-emerald-900/40 active:scale-95"
>
<Check className="h-6 w-6" />
</button>
</div>
</div>
</div>
);
}
return (
<div className="absolute inset-0 overflow-y-auto bg-[radial-gradient(circle_at_top,#113a3d_0%,#071719_45%,#020607_100%)] text-[#d6fff7]">
<div className="pointer-events-none absolute inset-0 opacity-20 [background-image:linear-gradient(rgba(130,255,228,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(130,255,228,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_55%,rgba(0,0,0,0.34)_100%)]" />
<div className="relative flex min-h-full flex-col px-10 py-8 pb-14">
<div className="flex items-start justify-between gap-6">
<div>
<div className="flex items-center gap-3 text-[12px] uppercase tracking-[0.32em] text-[#83fff0]/70">
<Landmark className="h-4 w-4" />
OpenClaw Treasury ATM
</div>
<div className="mt-3 text-[13px] uppercase tracking-[0.24em] text-[#7ddfd2]/62">
Token Usage Ledger
</div>
<div className="mt-2 text-[44px] font-semibold tracking-[0.08em] text-[#dbfff6]">
{formatNumber(usage.totals.totalTokens)}
</div>
<div className="mt-2 text-[15px] uppercase tracking-[0.28em] text-[#89fff1]/72">
Total tokens used
</div>
<div className="mt-4 inline-flex items-center rounded-full border border-[#7cffef]/20 bg-black/20 px-4 py-2 text-[13px] uppercase tracking-[0.24em] text-[#bafff7]/85">
USD equivalent {formatCurrency(usage.totals.totalCost)}
</div>
</div>
<div className="w-[320px] rounded-[24px] border border-[#7dfff0]/18 bg-black/22 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.34)]">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.24em] text-[#88fff1]/62">
<Wallet className="h-4 w-4" />
Account summary
</div>
<div className="mt-4 flex flex-wrap gap-2">
{[
{ label: "7D", value: 7 },
{ label: "30D", value: 30 },
{ label: "MTD", value: "mtd" as const },
].map((range) => (
<button
key={range.label}
type="button"
onClick={() => setQuickRange(range.value)}
className={`rounded-full border px-3 py-1.5 text-[10px] uppercase tracking-[0.22em] transition-colors ${
selectedRangeLabel === range.label
? "border-[#8efff2]/40 bg-[#0d3034] text-[#dffff8]"
: "border-[#7dfff0]/16 bg-[#041315] text-[#8ffff3]/68 hover:border-[#7dfff0]/30 hover:text-[#dffff8]"
}`}
>
{range.label}
</button>
))}
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<SummaryCard label="Input" value={formatCurrency(usage.totals.inputCost)} />
<SummaryCard label="Output" value={formatCurrency(usage.totals.outputCost)} />
<SummaryCard label="Cache read" value={formatCurrency(usage.totals.cacheReadCost)} />
<SummaryCard label="Cache write" value={formatCurrency(usage.totals.cacheWriteCost)} />
</div>
<div className="mt-4 rounded-2xl border border-[#7dfff0]/12 bg-[#031314]/80 px-4 py-3 text-[12px] uppercase tracking-[0.18em] text-[#9ffef0]/76">
{usage.lastRefreshedAt
? `Last refresh ${new Date(usage.lastRefreshedAt).toLocaleTimeString()}`
: settingsLoaded
? "Awaiting first usage snapshot"
: "Loading account preferences"}
</div>
</div>
</div>
<div className="mt-7 space-y-6">
<SectionCard
title="Usage Overview"
subtitle="Expanded OpenClaw expense data for the selected ledger window."
action={
<button
type="button"
onClick={() => void usage.refresh()}
className="inline-flex items-center gap-2 rounded-full border border-[#7dfff0]/24 bg-[#072528] px-4 py-2 text-[11px] uppercase tracking-[0.22em] text-[#b7fff8] transition-colors hover:border-[#7dfff0]/40 hover:bg-[#0a3035]"
>
<RefreshCw className={`h-3.5 w-3.5 ${usage.loading ? "animate-spin" : ""}`} />
Refresh
</button>
}
>
{usage.error ? <EmptyPanelState message={usage.error} tone="danger" /> : null}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{overviewCards.map((card) => (
<SummaryCard key={card.label} label={card.label} value={card.value} />
))}
</div>
</SectionCard>
<SectionCard
title="Daily Withdrawals"
subtitle="Recent cost movement across the last seven days."
>
{usage.loading && recentCostDaily.length === 0 ? (
<EmptyPanelState message="Loading ATM ledger." />
) : recentCostDaily.length === 0 ? (
<EmptyPanelState message="No token spend recorded for the current ledger window." />
) : (
<div className="grid grid-cols-7 gap-3">
{recentCostDaily.map((entry) => {
const heightPct = chartMax > 0 ? (entry.totalCost / chartMax) * 100 : 0;
return (
<div key={entry.date} className="flex min-w-0 flex-col items-center gap-3">
<div className="text-center text-[11px] uppercase tracking-[0.12em] text-[#9dfef0]/68">
{formatCurrency(entry.totalCost)}
</div>
<div className="flex h-[230px] w-full items-end rounded-[20px] border border-[#7dfff0]/10 bg-[#041315]/86 p-2">
<div
className="w-full rounded-[14px] bg-[linear-gradient(180deg,#7effef_0%,#2cd3bf_100%)] shadow-[0_0_18px_rgba(85,255,231,0.32)]"
style={{ height: `${Math.max(8, heightPct)}%` }}
title={`${entry.date} ${formatCurrency(entry.totalCost)}`}
/>
</div>
<div className="text-[11px] uppercase tracking-[0.16em] text-[#7bd9cd]/72">
{entry.date.slice(5)}
</div>
</div>
);
})}
</div>
)}
</SectionCard>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<SectionCard
title="Activity By Day"
subtitle="Daily tokens, cost, messages, tool calls, and errors."
>
<div className="space-y-3">
{usage.aggregates.daily.map((entry) => (
<ListRow
key={entry.date}
title={entry.date}
primary={`${formatCurrency(entry.cost)} · ${formatNumber(entry.tokens)} tokens`}
secondary={`${formatNumber(entry.messages)} messages · ${formatNumber(entry.toolCalls)} tool calls · ${formatNumber(entry.errors)} errors`}
/>
))}
{usage.aggregates.daily.length === 0 ? (
<EmptyPanelState message="No daily activity rows available yet." />
) : null}
</div>
</SectionCard>
<SectionCard
title="Budget Alerts"
subtitle="Threshold warnings for daily, monthly, and per-agent spend."
>
<div className="space-y-3">
{usage.budgetAlerts.map((alert) => (
<div
key={alert.key}
className={`rounded-2xl border px-4 py-4 text-[13px] ${
alert.severity === "danger"
? "border-rose-400/35 bg-rose-500/12 text-rose-100"
: "border-amber-300/30 bg-amber-400/12 text-amber-50"
}`}
>
<div className="text-[11px] uppercase tracking-[0.18em] opacity-70">
{alert.label}
</div>
<div className="mt-2 text-[16px]">
{formatCurrency(alert.currentUsd)} / {formatCurrency(alert.limitUsd)}.
</div>
</div>
))}
{usage.budgetAlerts.length === 0 ? (
<EmptyPanelState
message="Budget thresholds are healthy for the current ATM ledger window."
tone="success"
/>
) : null}
</div>
</SectionCard>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<SectionCard title="Agent Expenses" subtitle="All agents ranked by total spend.">
<div className="space-y-3">
{usage.aggregates.byAgent.map((entry, index) => (
<ListRow
key={entry.agentId}
title={`Account ${String(index + 1).padStart(2, "0")} · ${entry.agentName}`}
primary={formatCurrency(entry.totals.totalCost)}
secondary={`${formatNumber(entry.totals.totalTokens)} tokens`}
/>
))}
{usage.aggregates.byAgent.length === 0 ? (
<EmptyPanelState message="No agent token activity yet." />
) : null}
</div>
</SectionCard>
<SectionCard
title="Model Expenses"
subtitle="Provider and model spend breakdown."
>
<div className="space-y-3">
{usage.aggregates.byModel.map((entry, index) => (
<ListRow
key={`${entry.provider ?? "unknown"}:${entry.model ?? "unknown"}`}
title={`Route ${String(index + 1).padStart(2, "0")} · ${entry.provider ?? "unknown"} / ${entry.model ?? "unknown"}`}
primary={formatCurrency(entry.totals.totalCost)}
secondary={`${formatNumber(entry.totals.totalTokens)} tokens`}
/>
))}
{usage.aggregates.byModel.length === 0 ? (
<EmptyPanelState message="No model cost routes recorded yet." />
) : null}
</div>
</SectionCard>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<SectionCard title="Tool Usage" subtitle="All tools observed in the selected sessions.">
<div className="space-y-3">
{usage.aggregates.tools.tools.map((tool, index) => (
<ListRow
key={tool.name}
title={`Tool ${String(index + 1).padStart(2, "0")} · ${tool.name}`}
primary={formatNumber(tool.count)}
secondary="total invocations"
/>
))}
{usage.aggregates.tools.tools.length === 0 ? (
<EmptyPanelState message="No tool usage has been recorded yet." />
) : null}
</div>
</SectionCard>
<SectionCard
title="Message Totals"
subtitle="Conversation activity across all selected sessions."
>
<div className="grid grid-cols-2 gap-3">
<SummaryCard
label="All Messages"
value={formatNumber(usage.aggregates.messages.total)}
/>
<SummaryCard
label="User"
value={formatNumber(usage.aggregates.messages.user)}
/>
<SummaryCard
label="Assistant"
value={formatNumber(usage.aggregates.messages.assistant)}
/>
<SummaryCard
label="Tool Results"
value={formatNumber(usage.aggregates.messages.toolResults)}
/>
</div>
</SectionCard>
</div>
<SectionCard
title="Recent Sessions"
subtitle="Latest sessions with cost and token totals."
>
<div className="space-y-3">
{recentSessions.map((session) => (
<ListRow
key={session.key}
title={session.label ?? session.agentName ?? session.key}
primary={`${formatCurrency(session.usage.totals.totalCost)} · ${formatNumber(
session.usage.totals.totalTokens,
)} tokens`}
secondary={`${session.provider ?? "unknown"} / ${session.model ?? "unknown"} · ${
session.updatedAt
? new Date(session.updatedAt).toLocaleString()
: "no timestamp"
}`}
/>
))}
{recentSessions.length === 0 ? (
<EmptyPanelState message="No sessions available for the selected range." />
) : null}
</div>
</SectionCard>
</div>
</div>
</div>
);
}
function SectionCard({
title,
subtitle,
action,
children,
}: {
title: string;
subtitle: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="rounded-[28px] border border-[#7dfff0]/16 bg-black/20 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[12px] uppercase tracking-[0.24em] text-[#8cfff3]/64">
{title}
</div>
<div className="mt-2 text-[14px] text-[#d8fff7]/74">{subtitle}</div>
</div>
{action}
</div>
<div className="mt-5">{children}</div>
</section>
);
}
function SummaryCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-[#7dfff0]/10 bg-[#031314]/78 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-[#7addcf]/58">
{label}
</div>
<div className="mt-2 text-[16px] text-[#e4fff9]">{value}</div>
</div>
);
}
function ListRow({
title,
primary,
secondary,
}: {
title: string;
primary: string;
secondary: string;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-2xl border border-[#7dfff0]/10 bg-[#031314]/78 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-[13px] uppercase tracking-[0.12em] text-[#dffef8]">
{title}
</div>
<div className="mt-1 text-[11px] text-[#8cdcd1]/66">{secondary}</div>
</div>
<div className="shrink-0 text-right text-[15px] text-[#d9fff8]">{primary}</div>
</div>
);
}
function EmptyPanelState({
message,
tone = "neutral",
}: {
message: string;
tone?: "neutral" | "success" | "danger";
}) {
const toneClass =
tone === "danger"
? "border-rose-400/30 bg-rose-500/12 text-rose-100"
: tone === "success"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-100"
: "border-[#7dfff0]/10 bg-[#031314]/78 text-[#b6fff7]/70";
return (
<div className={`rounded-2xl border px-4 py-4 text-[13px] ${toneClass}`}>
{message}
</div>
);
}
@@ -0,0 +1,904 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import {
ExternalLink,
Github,
RefreshCw,
ShieldCheck,
ShieldX,
MessageSquare,
} from "lucide-react";
import type {
GitHubDashboardResponse,
GitHubDetailResponse,
GitHubInlineCommentSide,
GitHubPullRequestDetail,
GitHubPullRequestSummary,
GitHubReviewAction,
} from "@/lib/office/github";
import { resolveSkillMarketplaceMetadata } from "@/lib/skills/marketplace";
import {
buildSkillMissingDetails,
deriveSkillReadinessState,
type SkillReadinessState,
} from "@/lib/skills/presentation";
import type { SkillStatusEntry } from "@/lib/skills/types";
import { FileDiffModal } from "./github/FileDiffModal";
import { useBrowserPreview } from "./github/useBrowserPreview";
import {
GITHUB_RECORDING_PRIVACY_MASK_ACTIVE,
formatRelativeTime,
maskGitHubRecordingText,
summarizeChecksTone,
} from "./github/utils";
type GithubImmersiveScreenProps = {
agentName?: string | null;
githubSkill?: SkillStatusEntry | null;
onOpenSetup?: () => void;
};
export function GithubImmersiveScreen({
agentName,
githubSkill = null,
onOpenSetup,
}: GithubImmersiveScreenProps) {
const [dashboard, setDashboard] = useState<GitHubDashboardResponse | null>(
null,
);
const [detail, setDetail] = useState<GitHubPullRequestDetail | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"queue" | "repo">("queue");
const [selectedPr, setSelectedPr] = useState<GitHubPullRequestSummary | null>(
null,
);
const [reviewBody, setReviewBody] = useState("");
const [reviewBusyAction, setReviewBusyAction] =
useState<GitHubReviewAction | null>(null);
const [reviewMessage, setReviewMessage] = useState<string | null>(null);
const [detailMode, setDetailMode] = useState<"summary" | "browser">(
"summary",
);
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const requestIdRef = useRef(0);
const detailRequestIdRef = useRef(0);
const skillReadiness = useMemo<SkillReadinessState | null>(
() => (githubSkill ? deriveSkillReadinessState(githubSkill) : null),
[githubSkill],
);
const skillMissingDetails = useMemo(
() => (githubSkill ? buildSkillMissingDetails(githubSkill) : []),
[githubSkill],
);
const skillMetadata = useMemo(
() => (githubSkill ? resolveSkillMarketplaceMetadata(githubSkill) : null),
[githubSkill],
);
const refreshDashboard = useCallback(async () => {
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setLoading(true);
setError(null);
try {
const response = await fetch("/api/office/github", { cache: "no-store" });
const payload = (await response.json()) as GitHubDashboardResponse & {
error?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to load GitHub dashboard.",
);
}
if (requestIdRef.current !== requestId) return;
setDashboard(payload);
setSelectedPr((current) => {
if (!current) {
return (
payload.reviewRequests[0] ??
payload.currentRepoPullRequests[0] ??
payload.authoredPullRequests[0] ??
null
);
}
return (
[
...payload.reviewRequests,
...payload.currentRepoPullRequests,
...payload.authoredPullRequests,
].find(
(entry) =>
entry.repo === current.repo && entry.number === current.number,
) ?? current
);
});
} catch (error) {
if (requestIdRef.current !== requestId) return;
setDashboard(null);
setError(
error instanceof Error
? error.message
: "Unable to load GitHub dashboard.",
);
} finally {
if (requestIdRef.current === requestId) {
setLoading(false);
}
}
}, []);
useEffect(() => {
void refreshDashboard();
}, [refreshDashboard]);
const loadDetail = useCallback(
async (summary: GitHubPullRequestSummary | null) => {
if (!summary) {
detailRequestIdRef.current += 1;
setDetail(null);
setSelectedFilePath(null);
return;
}
const requestId = detailRequestIdRef.current + 1;
detailRequestIdRef.current = requestId;
setDetailLoading(true);
try {
const params = new URLSearchParams({
repo: summary.repo,
number: String(summary.number),
});
const response = await fetch(
`/api/office/github?${params.toString()}`,
{
cache: "no-store",
},
);
const payload = (await response.json()) as GitHubDetailResponse & {
error?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to load pull request details.",
);
}
if (detailRequestIdRef.current !== requestId) return;
setDetail(payload.pullRequest);
setSelectedFilePath(null);
} catch (error) {
if (detailRequestIdRef.current !== requestId) return;
setDetail(null);
setSelectedFilePath(null);
setError(
error instanceof Error
? error.message
: "Unable to load pull request details.",
);
} finally {
if (detailRequestIdRef.current === requestId) {
setDetailLoading(false);
}
}
},
[],
);
useEffect(() => {
void loadDetail(selectedPr);
}, [loadDetail, selectedPr]);
const browserPreview = useBrowserPreview(
detail?.url ?? null,
detailMode === "browser" && !GITHUB_RECORDING_PRIVACY_MASK_ACTIVE,
);
const queueEntries = useMemo(
() =>
dashboard
? [
...dashboard.reviewRequests,
...dashboard.authoredPullRequests,
].filter(
(entry, index, list) =>
list.findIndex(
(candidate) =>
candidate.repo === entry.repo &&
candidate.number === entry.number,
) === index,
)
: [],
[dashboard],
);
const activeList =
activeTab === "queue"
? queueEntries
: (dashboard?.currentRepoPullRequests ?? []);
const currentRepoLabel = useMemo(() => {
const slug = dashboard?.currentRepoSlug?.trim();
if (!slug) return "No git remote";
const segments = slug.split("/").filter(Boolean);
return maskGitHubRecordingText(segments.at(-1) ?? slug);
}, [dashboard?.currentRepoSlug]);
const isInitialLoading = loading && dashboard === null && !error;
const selectedFile = useMemo(
() => detail?.files.find((file) => file.path === selectedFilePath) ?? null,
[detail?.files, selectedFilePath],
);
useEffect(() => {
if (!selectedFile) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSelectedFilePath(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedFile]);
const handleSelectPr = useCallback(
(summary: GitHubPullRequestSummary, tab: "queue" | "repo") => {
setActiveTab(tab);
setSelectedPr(summary);
setSelectedFilePath(null);
setReviewBody("");
setReviewMessage(null);
},
[],
);
const handleSubmitReview = useCallback(
async (action: GitHubReviewAction) => {
if (!detail) return;
setReviewBusyAction(action);
setReviewMessage(null);
setError(null);
try {
const response = await fetch("/api/office/github", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo: detail.repo,
number: detail.number,
action,
body: reviewBody,
}),
});
const payload = (await response.json()) as {
error?: string;
message?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to submit GitHub review.",
);
}
setReviewMessage(payload.message?.trim() || "Review submitted.");
setReviewBody("");
await Promise.all([refreshDashboard(), loadDetail(selectedPr)]);
} catch (error) {
setError(
error instanceof Error
? error.message
: "Unable to submit GitHub review.",
);
} finally {
setReviewBusyAction(null);
}
},
[detail, loadDetail, refreshDashboard, reviewBody, selectedPr],
);
const handleSubmitInlineComment = useCallback(
async (input: {
repo: string;
pullNumber: number;
commitId: string | null;
path: string;
line: number;
side: GitHubInlineCommentSide;
body: string;
}) => {
const response = await fetch("/api/office/github", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo: input.repo,
number: input.pullNumber,
commitId: input.commitId,
path: input.path,
line: input.line,
side: input.side,
body: input.body,
}),
});
const payload = (await response.json()) as { error?: string; message?: string };
if (!response.ok) {
const message =
payload.error?.trim() || "Unable to submit the GitHub inline comment.";
throw new Error(message);
}
},
[],
);
const shouldBlockForSkillSetup =
githubSkill !== null &&
skillReadiness !== null &&
skillReadiness !== "ready";
if (shouldBlockForSkillSetup) {
return (
<div className="flex h-full flex-col bg-[#050816] text-white">
<div className="border-b border-cyan-500/10 bg-[#071122] px-8 py-6">
<div className="flex items-center gap-3 text-cyan-200">
<Github className="h-6 w-6" />
<div>
<div className="text-[11px] uppercase tracking-[0.28em] text-cyan-200/70">
Code Review Room
</div>
<div className="text-xl font-semibold">
GitHub skill setup required.
</div>
</div>
</div>
</div>
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-xl rounded-3xl border border-cyan-400/15 bg-[#081427] p-8 shadow-[0_30px_120px_rgba(0,0,0,0.55)]">
<div className="mb-4 flex items-center gap-3 text-cyan-100">
<ShieldX className="h-5 w-5 text-amber-300" />
<span className="text-sm uppercase tracking-[0.24em] text-cyan-100/70">
{skillMetadata?.tagline ??
"GitHub access is not ready for this agent."}
</span>
</div>
<div className="text-2xl font-semibold text-white">
{skillReadiness === "disabled-globally"
? "GitHub is disabled for this gateway."
: skillReadiness === "unavailable"
? "This agent cannot use the GitHub skill yet."
: "The GitHub skill still needs setup."}
</div>
<p className="mt-3 text-sm leading-6 text-cyan-100/72">
Open the Skills panel to install or enable the bundled GitHub
skill so agents can walk here and review pull requests through
OpenClaw.
</p>
<div className="mt-5 space-y-2">
{skillMissingDetails.length > 0 ? (
skillMissingDetails.map((line) => (
<div
key={line}
className="rounded-2xl border border-white/6 bg-black/20 px-4 py-3 text-sm text-white/72"
>
{line}
</div>
))
) : (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-3 text-sm text-white/72">
Enable the GitHub skill for the selected agent, then come back
to the code review room.
</div>
)}
</div>
{onOpenSetup ? (
<button
type="button"
onClick={onOpenSetup}
className="mt-6 inline-flex items-center gap-2 rounded-full border border-cyan-300/30 bg-cyan-300/10 px-5 py-2.5 text-sm font-medium text-cyan-100 transition-colors hover:border-cyan-200/50 hover:bg-cyan-300/18"
>
<ShieldCheck className="h-4 w-4" />
Open Skills Setup
</button>
) : null}
</div>
</div>
</div>
);
}
return (
<div className="relative flex h-full flex-col overflow-hidden bg-[radial-gradient(circle_at_top,#0f1b3d_0%,#060916_42%,#020409_100%)] text-white">
<div className="border-b border-cyan-400/12 bg-[#06101f]/82 px-6 py-4 backdrop-blur-sm">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-cyan-300/18 bg-cyan-300/8">
<Github className="h-5 w-5 text-cyan-100" />
</div>
<div>
<div className="text-[11px] uppercase tracking-[0.28em] text-cyan-200/65">
Code Review Room
</div>
<div className="text-lg font-semibold text-white">
{agentName
? `${agentName} is reviewing GitHub.`
: "GitHub review station."}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
void refreshDashboard();
void loadDetail(selectedPr);
}}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[12px] text-white/72 transition-colors hover:border-white/20 hover:text-white"
>
<RefreshCw
className={`h-3.5 w-3.5 ${loading || detailLoading ? "animate-spin" : ""}`}
/>
Refresh
</button>
{dashboard?.viewerLogin ? (
<div className="rounded-full border border-cyan-300/16 bg-cyan-300/8 px-3 py-1.5 text-[12px] text-cyan-100/90">
@{maskGitHubRecordingText(dashboard.viewerLogin)}
</div>
) : null}
</div>
</div>
</div>
{error ? (
<div className="mx-6 mt-4 rounded-2xl border border-rose-400/16 bg-rose-400/8 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
{reviewMessage ? (
<div className="mx-6 mt-4 rounded-2xl border border-emerald-400/16 bg-emerald-400/8 px-4 py-3 text-sm text-emerald-100">
{reviewMessage}
</div>
) : null}
{dashboard && !dashboard.ready && dashboard.message ? (
<div className="mx-6 mt-4 rounded-2xl border border-amber-300/16 bg-amber-300/10 px-4 py-3 text-sm text-amber-100">
{dashboard.message}
</div>
) : null}
{isInitialLoading ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-8 py-10">
<div className="flex max-w-md flex-col items-center rounded-3xl border border-cyan-300/12 bg-[#081122]/78 px-8 py-10 text-center shadow-[0_20px_80px_rgba(0,0,0,0.38)]">
<div className="flex h-14 w-14 items-center justify-center rounded-full border border-cyan-300/18 bg-cyan-300/8">
<RefreshCw className="h-6 w-6 animate-spin text-cyan-100" />
</div>
<div className="mt-5 text-[11px] uppercase tracking-[0.28em] text-cyan-100/55">
Loading GitHub
</div>
<div className="mt-2 text-lg font-semibold text-white">
Fetching your review queue.
</div>
<div className="mt-2 text-sm text-white/58">
Pull requests, repo metadata, and review details are loading now.
</div>
</div>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-[340px_minmax(0,1fr)] gap-0">
<div className="flex min-h-0 flex-col border-r border-white/6 bg-[#081122]/72">
<div className="grid grid-cols-2 gap-2 p-3">
<button
type="button"
onClick={() => setActiveTab("queue")}
className={`min-w-0 rounded-2xl px-4 py-2.5 text-left transition-colors ${
activeTab === "queue"
? "border border-cyan-300/20 bg-cyan-300/12 text-white"
: "border border-white/6 bg-white/4 text-white/65 hover:text-white"
}`}
>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/50">
My Queue
</div>
<div className="mt-1 text-lg font-semibold leading-none">
{queueEntries.length}
</div>
</button>
<button
type="button"
onClick={() => setActiveTab("repo")}
className={`min-w-0 rounded-2xl px-4 py-2.5 text-left transition-colors ${
activeTab === "repo"
? "border border-cyan-300/20 bg-cyan-300/12 text-white"
: "border border-white/6 bg-white/4 text-white/65 hover:text-white"
}`}
>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/50">
Current Repo
</div>
<div className="mt-1 break-words text-sm font-medium leading-5 text-white/85">
{currentRepoLabel}
</div>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
{loading ? (
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4 text-sm text-white/55">
Loading pull requests.
</div>
) : activeList.length === 0 ? (
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4 text-sm text-white/55">
{activeTab === "queue"
? "No review requests or authored pull requests found."
: "No open pull requests found for this repository."}
</div>
) : (
<div className="space-y-3">
{activeList.map((entry) => {
const isSelected =
selectedPr?.repo === entry.repo &&
selectedPr?.number === entry.number;
return (
<button
key={`${entry.repo}#${entry.number}`}
type="button"
onClick={() => handleSelectPr(entry, activeTab)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition-colors ${
isSelected
? "border-cyan-300/24 bg-cyan-300/10"
: "border-white/6 bg-white/4 hover:border-white/12 hover:bg-white/6"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
{maskGitHubRecordingText(entry.repo)}
</div>
<div className="mt-1 text-sm font-semibold text-white">
#{entry.number} {maskGitHubRecordingText(entry.title)}
</div>
</div>
{entry.isDraft ? (
<span className="rounded-full border border-white/10 px-2 py-1 text-[10px] uppercase tracking-[0.2em] text-white/55">
Draft
</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-white/56">
<span>@{maskGitHubRecordingText(entry.author)}</span>
<span>{formatRelativeTime(entry.updatedAt)}</span>
{entry.statusSummary ? (
<span
className={summarizeChecksTone(
entry.statusSummary,
)}
>
{entry.statusSummary}
</span>
) : null}
</div>
</button>
);
})}
</div>
)}
</div>
</div>
<div className="min-h-0 overflow-hidden">
{detailLoading ? (
<div className="flex h-full items-center justify-center text-sm text-white/55">
Loading pull request details.
</div>
) : detail ? (
<div className="grid h-full grid-cols-[minmax(0,1fr)_320px]">
<div className="min-h-0 overflow-y-auto px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[11px] uppercase tracking-[0.26em] text-cyan-200/55">
{maskGitHubRecordingText(detail.repo)}
</div>
<div className="mt-1 text-2xl font-semibold text-white">
#{detail.number} {maskGitHubRecordingText(detail.title)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-white/62">
<span>@{maskGitHubRecordingText(detail.author)}</span>
<span>{formatRelativeTime(detail.updatedAt)}</span>
{detail.reviewDecision ? (
<span>{detail.reviewDecision}</span>
) : null}
{detail.mergeable ? (
<span>{detail.mergeable}</span>
) : null}
</div>
</div>
<a
href={detail.url}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-[96px] shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/5 px-3 py-2 text-[12px] text-white/75 transition-colors hover:border-white/20 hover:text-white"
>
<ExternalLink className="h-3.5 w-3.5" />
Open PR
</a>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Checks
</div>
<div className="mt-2 text-lg font-semibold text-white">
{detail.statusChecks.length}
</div>
</div>
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Files Changed
</div>
<div className="mt-2 text-lg font-semibold text-white">
{detail.files.length}
</div>
</div>
<div className="rounded-2xl border border-white/6 bg-white/4 px-4 py-4">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Reviews
</div>
<div className="mt-2 text-lg font-semibold text-white">
{detail.reviews.length}
</div>
</div>
</div>
<div className="mt-5 rounded-3xl border border-cyan-400/10 bg-[#071223]/88 p-5">
<div className="space-y-4">
<div>
<div className="text-[11px] uppercase tracking-[0.24em] text-cyan-100/52">
Review Actions
</div>
<div className="mt-1 text-sm text-white/68">
Submit the review directly from the server room.
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void handleSubmitReview("APPROVE")}
disabled={Boolean(reviewBusyAction)}
className="inline-flex h-11 items-center justify-center whitespace-nowrap rounded-full border border-emerald-300/24 bg-emerald-300/10 px-4 text-sm text-emerald-100 transition-colors hover:border-emerald-200/40 hover:bg-emerald-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{reviewBusyAction === "APPROVE"
? "Approving..."
: "Approve"}
</button>
<button
type="button"
onClick={() =>
void handleSubmitReview("REQUEST_CHANGES")
}
disabled={Boolean(reviewBusyAction)}
className="inline-flex h-11 items-center justify-center whitespace-nowrap rounded-full border border-amber-300/24 bg-amber-300/10 px-4 text-xs text-amber-100 transition-colors hover:border-amber-200/40 hover:bg-amber-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{reviewBusyAction === "REQUEST_CHANGES"
? "Requesting..."
: "Request Changes"}
</button>
<button
type="button"
onClick={() => void handleSubmitReview("COMMENT")}
disabled={Boolean(reviewBusyAction)}
className="inline-flex h-11 items-center justify-center whitespace-nowrap rounded-full border border-cyan-300/24 bg-cyan-300/10 px-4 text-sm text-cyan-100 transition-colors hover:border-cyan-200/40 hover:bg-cyan-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{reviewBusyAction === "COMMENT"
? "Sending..."
: "Comment"}
</button>
</div>
</div>
<textarea
value={reviewBody}
onChange={(event) => setReviewBody(event.target.value)}
placeholder="Add an approval note or request changes summary."
className="mt-4 h-28 w-full resize-none rounded-2xl border border-white/8 bg-black/22 px-4 py-3 text-sm text-white outline-none placeholder:text-white/28"
/>
</div>
<div className="mt-5 flex items-center gap-2">
<button
type="button"
onClick={() => setDetailMode("summary")}
className={`rounded-full px-3 py-1.5 text-[12px] transition-colors ${
detailMode === "summary"
? "border border-white/10 bg-white/10 text-white"
: "border border-white/6 bg-white/4 text-white/58 hover:text-white"
}`}
>
Summary
</button>
<button
type="button"
onClick={() => setDetailMode("browser")}
className={`rounded-full px-3 py-1.5 text-[12px] transition-colors ${
detailMode === "browser"
? "border border-white/10 bg-white/10 text-white"
: "border border-white/6 bg-white/4 text-white/58 hover:text-white"
}`}
>
Browser Preview
</button>
</div>
{detailMode === "summary" ? (
<>
<div className="mt-5 rounded-3xl border border-white/6 bg-white/4 p-5">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Description
</div>
<div className="mt-3 whitespace-pre-wrap text-sm leading-6 text-white/78">
{maskGitHubRecordingText(detail.body) ||
"No pull request description."}
</div>
</div>
<div className="mt-5 rounded-3xl border border-white/6 bg-white/4 p-5">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/45">
Diff Preview
</div>
<div className="mt-1 text-sm text-white/72">
Full pull request diff preview.
</div>
<pre className="mt-3 max-h-[320px] overflow-auto rounded-2xl border border-white/6 bg-black/28 p-4 text-[12px] leading-5 text-cyan-100/86">
{maskGitHubRecordingText(detail.diff) ||
"Diff preview unavailable."}
</pre>
{detail.diffTruncated ? (
<div className="mt-2 text-[11px] text-white/45">
Diff preview truncated for performance.
</div>
) : null}
</div>
</>
) : (
<div className="mt-5 rounded-3xl border border-white/6 bg-white/4 p-5">
<div className="mb-3 text-[11px] uppercase tracking-[0.24em] text-white/45">
Browser Preview
</div>
{GITHUB_RECORDING_PRIVACY_MASK_ACTIVE ? (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-5 text-sm text-white/55">
Browser preview is temporarily disabled so the screen
recording does not reveal the real GitHub username.
</div>
) : browserPreview.loading ? (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-5 text-sm text-white/55">
Capturing GitHub preview.
</div>
) : browserPreview.mediaUrl ? (
<Image
src={browserPreview.mediaUrl}
alt={`Preview of ${detail.url}`}
width={1280}
height={720}
unoptimized
className="h-auto w-full rounded-2xl border border-white/8 object-cover"
/>
) : (
<div className="rounded-2xl border border-white/6 bg-black/20 px-4 py-5 text-sm text-white/55">
{browserPreview.error ??
"Browser preview unavailable on this setup."}
</div>
)}
</div>
)}
</div>
<div className="min-h-0 overflow-y-auto border-l border-white/6 bg-[#060d19]/86 px-4 py-5">
<div className="text-[11px] uppercase tracking-[0.24em] text-white/42">
Checks
</div>
<div className="mt-3 space-y-2">
{detail.statusChecks.length > 0 ? (
detail.statusChecks.map((check) => (
<div
key={`${check.name}-${check.detailsUrl ?? "local"}`}
className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3"
>
<div className="text-sm font-medium text-white">
{check.name}
</div>
<div className="mt-1 text-[12px] text-white/55">
{[check.status, check.conclusion, check.workflow]
.filter(Boolean)
.join(" · ") || "No status"}
</div>
</div>
))
) : (
<div className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3 text-sm text-white/55">
No checks reported.
</div>
)}
</div>
<div className="mt-6 text-[11px] uppercase tracking-[0.24em] text-white/42">
Files
</div>
<div className="mt-3 space-y-2">
{detail.files.slice(0, 12).map((file) => (
<button
key={file.path}
type="button"
onClick={() => setSelectedFilePath(file.path)}
className={`w-full rounded-2xl border px-3 py-3 text-left transition-colors ${
selectedFile?.path === file.path
? "border-cyan-300/24 bg-cyan-300/10"
: "border-white/6 bg-white/4 hover:border-white/12 hover:bg-white/6"
}`}
>
<div className="truncate text-sm text-white">
{file.path}
</div>
<div className="mt-1 text-[12px] text-white/55">
+{file.additions} / -{file.deletions}
</div>
{file.status ? (
<div className="mt-1 text-[11px] uppercase tracking-[0.16em] text-white/40">
{file.status}
</div>
) : null}
</button>
))}
</div>
<div className="mt-6 text-[11px] uppercase tracking-[0.24em] text-white/42">
Recent Reviews
</div>
<div className="mt-3 space-y-2">
{detail.reviews.slice(0, 6).length > 0 ? (
detail.reviews.slice(0, 6).map((review, index) => (
<div
key={`${review.author}-${review.submittedAt ?? index}`}
className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3"
>
<div className="flex items-center gap-2 text-sm text-white">
<MessageSquare className="h-3.5 w-3.5 text-cyan-200/70" />
<span>@{maskGitHubRecordingText(review.author)}</span>
</div>
<div className="mt-1 text-[12px] text-white/55">
{[review.state, review.submittedAt]
.filter(Boolean)
.join(" · ")}
</div>
{review.body ? (
<div className="mt-2 text-[12px] leading-5 text-white/68">
{maskGitHubRecordingText(review.body)}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-white/6 bg-white/4 px-3 py-3 text-sm text-white/55">
No reviews yet.
</div>
)}
</div>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-white/55">
Select a pull request to inspect its checks, diff, and review
actions.
</div>
)}
</div>
</div>
)}
{selectedFile && detail ? (
<FileDiffModal
key={selectedFile.path}
file={selectedFile}
repo={detail.repo}
pullNumber={detail.number}
commitId={detail.headRefOid}
onSubmitInlineComment={handleSubmitInlineComment}
onClose={() => setSelectedFilePath(null)}
/>
) : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
"use client";
import { AudioLines, PhoneCall, Smartphone } from "lucide-react";
import type { MockPhoneCallScenario } from "@/lib/office/call/types";
export type PhoneCallStep =
| "dialing"
| "ringing"
| "speaking"
| "reply"
| "complete";
export function PhoneBoothImmersiveScreen({
scenario,
step,
typedDigits,
}: {
scenario: MockPhoneCallScenario;
step: PhoneCallStep;
typedDigits: string;
}) {
const statusLabel =
step === "dialing"
? "Dialing"
: step === "ringing"
? "Waiting for answer"
: step === "speaking"
? "Connected"
: step === "reply"
? "On the line"
: "Call complete";
return (
<div className="absolute inset-0 overflow-hidden bg-[radial-gradient(circle_at_top,#0f172a_0%,#050816_46%,#02030a_100%)] text-white">
<div className="pointer-events-none absolute inset-0 opacity-25 [background-image:linear-gradient(rgba(56,189,248,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(56,189,248,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="relative flex h-full items-center justify-center px-8 py-10">
<div className="grid w-full max-w-5xl grid-cols-[1.05fr_0.95fr] gap-10">
<div className="rounded-[32px] border border-sky-300/18 bg-slate-950/65 p-8 shadow-[0_24px_90px_rgba(2,8,23,0.75)]">
<div className="flex items-center gap-3 text-[11px] uppercase tracking-[0.28em] text-sky-200/70">
<PhoneCall className="h-4 w-4" />
Phone Booth Call
</div>
<div className="mt-4 text-4xl font-semibold tracking-[0.08em] text-sky-50">
{scenario.callee}
</div>
<div className="mt-2 text-sm uppercase tracking-[0.24em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-8 rounded-[28px] border border-sky-300/16 bg-slate-900/90 p-6">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/60">
<span>Calling from booth</span>
<span>{scenario.voiceAvailable ? "ElevenLabs ready" : "Text fallback"}</span>
</div>
<div className="mt-5 text-3xl font-medium tracking-[0.24em] text-sky-50">
{typedDigits || scenario.dialNumber}
</div>
<div className="mt-6 grid grid-cols-3 gap-3">
{["1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#"].map((digit) => (
<div
key={digit}
className={`flex h-14 items-center justify-center rounded-2xl border text-xl ${
typedDigits.includes(digit)
? "border-sky-300/40 bg-sky-400/16 text-sky-50"
: "border-slate-700 bg-slate-900/75 text-slate-300"
}`}
>
{digit}
</div>
))}
</div>
<div className="mt-5 flex items-center justify-end">
<div
className={`inline-flex items-center gap-3 rounded-2xl border px-5 py-3 text-sm uppercase tracking-[0.22em] transition-all ${
step === "dialing"
? "border-emerald-300/18 bg-emerald-400/8 text-emerald-100/72"
: "border-emerald-300/45 bg-emerald-400/18 text-emerald-50 shadow-[0_0_24px_rgba(74,222,128,0.22)]"
}`}
>
<PhoneCall className="h-4 w-4" />
{step === "dialing" ? "Ready to call" : "Calling"}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="relative h-[74vh] max-h-[720px] w-[360px] rounded-[44px] border border-sky-200/20 bg-[#020617] p-3 shadow-[0_30px_120px_rgba(0,0,0,0.78)]">
<div className="absolute left-1/2 top-3 h-1.5 w-28 -translate-x-1/2 rounded-full bg-slate-700" />
<div className="relative flex h-full flex-col overflow-hidden rounded-[34px] border border-sky-300/12 bg-[linear-gradient(180deg,#081225_0%,#020617_100%)] px-6 py-8">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/65">
<span>Cellular relay</span>
<Smartphone className="h-4 w-4" />
</div>
<div className="mt-8 flex h-28 w-28 items-center justify-center self-center rounded-full border border-sky-300/22 bg-sky-400/10 text-sky-100">
{step === "speaking" || step === "reply" ? (
<AudioLines className="h-12 w-12" />
) : (
<PhoneCall className="h-12 w-12" />
)}
</div>
<div className="mt-6 text-center">
<div className="text-[13px] uppercase tracking-[0.26em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-2 text-2xl font-semibold text-sky-50">
{scenario.callee}
</div>
<div className="mt-2 text-sm tracking-[0.22em] text-sky-200/60">
{scenario.dialNumber}
</div>
</div>
<div className="mt-8 flex-1 space-y-4">
<Bubble
label="Agent"
text={
step === "dialing"
? `Typing ${typedDigits || scenario.dialNumber}.`
: step === "ringing"
? `Pressed call and waiting for ${scenario.callee} to answer.`
: scenario.spokenText ?? "Preparing the line."
}
tone="primary"
/>
{step === "reply" || step === "complete" ? (
<Bubble
label={scenario.callee}
text={scenario.recipientReply ?? "The line is quiet."}
tone="secondary"
/>
) : null}
</div>
<div className="rounded-[24px] border border-sky-300/14 bg-slate-950/70 px-4 py-3 text-sm text-sky-100/78">
{scenario.statusLine}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function Bubble({
label,
text,
tone,
}: {
label: string;
text: string;
tone: "primary" | "secondary";
}) {
return (
<div
className={`rounded-[24px] border px-4 py-4 ${
tone === "primary"
? "border-sky-300/18 bg-sky-400/10 text-sky-50"
: "border-slate-700 bg-slate-900/90 text-slate-100"
}`}
>
<div className="text-[10px] uppercase tracking-[0.22em] opacity-60">{label}</div>
<div className="mt-2 text-sm leading-6">{text}</div>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { CheckCheck, MessageSquareText, Send, Smartphone } from "lucide-react";
import type { MockTextMessageScenario } from "@/lib/office/text/types";
export type TextMessageStep =
| "selecting_contact"
| "composing"
| "sending"
| "delivered"
| "reply"
| "complete";
export function SmsBoothImmersiveScreen({
scenario,
step,
typedMessage,
activeKey,
contacts,
activeContactIndex,
}: {
scenario: MockTextMessageScenario;
step: TextMessageStep;
typedMessage: string;
activeKey: string | null;
contacts: string[];
activeContactIndex: number | null;
}) {
const statusLabel =
step === "selecting_contact"
? "Selecting contact"
: step === "composing"
? "Composing"
: step === "sending"
? "Sending"
: step === "delivered"
? "Delivered"
: step === "reply"
? "Reply received"
: "Message complete";
const messageBody = typedMessage || scenario.messageText || "";
return (
<div className="absolute inset-0 overflow-hidden bg-[radial-gradient(circle_at_top,#0f172a_0%,#050816_48%,#02030a_100%)] text-white">
<div className="pointer-events-none absolute inset-0 opacity-20 [background-image:linear-gradient(rgba(56,189,248,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(56,189,248,0.08)_1px,transparent_1px)] [background-size:22px_22px]" />
<div className="relative flex h-full items-center justify-center px-8 py-10">
<div className="grid w-full max-w-5xl grid-cols-[1fr_0.92fr] gap-10">
<div className="rounded-[32px] border border-sky-300/18 bg-slate-950/65 p-8 shadow-[0_24px_90px_rgba(2,8,23,0.75)]">
<div className="flex items-center gap-3 text-[11px] uppercase tracking-[0.28em] text-sky-200/70">
<MessageSquareText className="h-4 w-4" />
Messaging Booth
</div>
<div className="mt-4 text-4xl font-semibold tracking-[0.08em] text-sky-50">
{scenario.recipient}
</div>
<div className="mt-2 text-sm uppercase tracking-[0.24em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-8 rounded-[28px] border border-sky-300/16 bg-slate-900/90 p-6">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/60">
<span>Typing from booth</span>
<span>iPhone relay</span>
</div>
<div className="mt-5 rounded-[24px] border border-slate-700 bg-slate-950/80 px-5 py-4 text-base leading-7 text-sky-50">
{messageBody || "Waiting for the first characters."}
{step === "composing" ? <span className="ml-1 inline-block animate-pulse">|</span> : null}
</div>
<div className="mt-5 flex items-center justify-end gap-3 text-sm uppercase tracking-[0.22em]">
<div className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/22 bg-sky-400/10 px-4 py-2 text-sky-100/80">
<Smartphone className="h-4 w-4" />
Active
</div>
<div className="inline-flex items-center gap-2 rounded-2xl border border-emerald-300/24 bg-emerald-400/10 px-4 py-2 text-emerald-100/80">
{step === "sending" ? <Send className="h-4 w-4" /> : <CheckCheck className="h-4 w-4" />}
{step === "composing" ? "Drafting" : statusLabel}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="relative h-[74vh] max-h-[720px] w-[360px] rounded-[44px] border border-sky-200/20 bg-[#020617] p-3 shadow-[0_30px_120px_rgba(0,0,0,0.78)]">
<div className="absolute left-1/2 top-3 h-1.5 w-28 -translate-x-1/2 rounded-full bg-slate-700" />
<div className="relative flex h-full flex-col overflow-hidden rounded-[34px] border border-sky-300/12 bg-[linear-gradient(180deg,#081225_0%,#020617_100%)] px-5 py-6">
<div className="flex items-center justify-between text-[11px] uppercase tracking-[0.24em] text-sky-200/65">
<span>Messages</span>
<Smartphone className="h-4 w-4" />
</div>
<div className="mt-5 text-center">
<div className="text-[13px] uppercase tracking-[0.26em] text-sky-200/55">
{statusLabel}
</div>
<div className="mt-2 text-2xl font-semibold text-sky-50">
{scenario.recipient}
</div>
</div>
<div className="mt-6 flex-1">
{step === "selecting_contact" ? (
<ContactList
contacts={contacts}
activeContactIndex={activeContactIndex}
/>
) : (
<div className="space-y-4">
<Bubble
align="right"
label="Agent"
text={messageBody || "Starting draft."}
tone="primary"
/>
{step === "delivered" || step === "reply" || step === "complete" ? (
<div className="text-right text-[11px] uppercase tracking-[0.2em] text-sky-200/45">
Delivered
</div>
) : null}
{step === "reply" || step === "complete" ? (
<Bubble
align="left"
label={scenario.recipient}
text={scenario.confirmationText ?? "Delivered."}
tone="secondary"
/>
) : null}
</div>
)}
</div>
<div className="mt-4 rounded-[24px] border border-sky-300/14 bg-slate-950/75 p-3">
<PhoneKeyboard activeKey={activeKey} />
</div>
<div className="rounded-[24px] border border-sky-300/14 bg-slate-950/70 px-4 py-3 text-sm text-sky-100/78">
{scenario.statusLine}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function ContactList({
contacts,
activeContactIndex,
}: {
contacts: string[];
activeContactIndex: number | null;
}) {
const selectedIndex = activeContactIndex ?? 0;
const windowStart = Math.max(
0,
Math.min(selectedIndex - 2, Math.max(contacts.length - 5, 0)),
);
const visibleContacts = contacts.slice(windowStart, windowStart + 5);
return (
<div className="relative h-full overflow-hidden rounded-[24px] border border-sky-300/14 bg-slate-950/55 px-3 py-3">
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-[linear-gradient(180deg,rgba(2,6,23,0.94),rgba(2,6,23,0))]" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-[linear-gradient(0deg,rgba(2,6,23,0.94),rgba(2,6,23,0))]" />
<div className="space-y-2 pt-3">
{visibleContacts.map((contact, index) => {
const absoluteIndex = windowStart + index;
const active = absoluteIndex === selectedIndex;
return (
<div
key={`${contact}-${absoluteIndex}`}
className={`rounded-[22px] border px-4 py-3 transition-all duration-150 ${
active
? "scale-[0.98] border-sky-200/70 bg-sky-300/20 text-sky-50 shadow-[0_0_20px_rgba(56,189,248,0.2)]"
: "border-slate-700/80 bg-slate-900/80 text-slate-200"
}`}
>
<div className="text-sm font-medium">{contact}</div>
<div className="mt-1 text-[11px] uppercase tracking-[0.18em] opacity-60">
{active ? "Opening conversation" : "Recent thread"}
</div>
</div>
);
})}
</div>
</div>
);
}
const KEYBOARD_ROWS = [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
["z", "x", "c", "v", "b", "n", "m", ",", ".", "?"],
] as const;
function PhoneKeyboard({ activeKey }: { activeKey: string | null }) {
return (
<div className="space-y-2">
{KEYBOARD_ROWS.map((row, rowIndex) => (
<div
key={row.join("")}
className={`flex gap-2 ${rowIndex === 1 ? "px-3" : rowIndex === 2 ? "px-6" : ""}`}
>
{row.map((keyValue) => (
<KeyboardKey
key={keyValue}
label={keyValue}
active={activeKey === keyValue}
/>
))}
</div>
))}
<div className="flex items-center gap-2">
<KeyboardKey label="123" active={false} className="w-[18%]" />
<KeyboardKey label="space" active={activeKey === "space"} className="flex-1" />
<KeyboardKey label="return" active={activeKey === "return"} className="w-[22%]" />
</div>
</div>
);
}
function KeyboardKey({
label,
active,
className = "",
}: {
label: string;
active: boolean;
className?: string;
}) {
return (
<div
className={`flex h-9 min-w-0 flex-1 items-center justify-center rounded-2xl border text-[12px] font-medium uppercase tracking-[0.12em] transition-all duration-100 ${
active
? "scale-[0.96] border-sky-200/70 bg-sky-300/30 text-sky-50 shadow-[0_0_20px_rgba(56,189,248,0.25)]"
: "border-slate-700/90 bg-slate-800/90 text-slate-200"
} ${className}`}
>
{label}
</div>
);
}
function Bubble({
align,
label,
text,
tone,
}: {
align: "left" | "right";
label: string;
text: string;
tone: "primary" | "secondary";
}) {
return (
<div className={align === "right" ? "ml-10" : "mr-10"}>
<div
className={`rounded-[24px] border px-4 py-4 ${
tone === "primary"
? "border-sky-300/18 bg-sky-400/10 text-sky-50"
: "border-slate-700 bg-slate-900/90 text-slate-100"
}`}
>
<div className="text-[10px] uppercase tracking-[0.22em] opacity-60">{label}</div>
<div className="mt-2 text-sm leading-6">{text}</div>
</div>
</div>
);
}
@@ -0,0 +1,231 @@
"use client";
import { ExternalLink, X } from "lucide-react";
import type { StandupMeeting } from "@/lib/office/standup/types";
const sourceTone = (ready: boolean, stale: boolean) => {
if (!ready) return stale ? "text-amber-200 border-amber-400/25" : "text-rose-200 border-rose-400/25";
return "text-emerald-200 border-emerald-400/25";
};
export function StandupImmersiveScreen({
meeting,
onClose,
}: {
meeting: StandupMeeting;
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-50 bg-[#05070b]/96 text-white">
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-cyan-500/15 px-6 py-4">
<div>
<div className="font-mono text-[11px] uppercase tracking-[0.28em] text-cyan-200/85">
Standup Board
</div>
<div className="mt-1 font-mono text-[12px] text-white/50">
{meeting.phase === "gathering"
? "Everyone is walking to the meeting room."
: meeting.phase === "in_progress"
? "Team updates are being presented."
: "Last standup snapshot."}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex items-center gap-2 rounded border border-white/10 bg-white/5 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.16em] text-white/70 transition-colors hover:border-white/20 hover:text-white"
>
<X className="h-4 w-4" />
Close
</button>
</div>
<div className="grid gap-4 border-b border-cyan-500/10 px-6 py-4 font-mono text-[11px] text-white/60 md:grid-cols-3">
<div>Phase: {meeting.phase}</div>
<div>Speaker: {meeting.currentSpeakerAgentId ?? "Waiting"}</div>
<div>
Progress: {meeting.arrivedAgentIds.length}/{meeting.participantOrder.length} arrived
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="grid gap-4 xl:grid-cols-3">
{meeting.cards.map((card) => {
const isSpeaking = card.agentId === meeting.currentSpeakerAgentId;
return (
<section
key={card.agentId}
className={`rounded-2xl border px-4 py-4 ${
isSpeaking
? "border-cyan-400/35 bg-cyan-500/[0.08]"
: "border-white/10 bg-white/[0.03]"
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-mono text-[11px] uppercase tracking-[0.18em] text-white/45">
Participant
</div>
<div className="mt-1 text-lg font-semibold text-white">
{card.agentName}
</div>
</div>
{isSpeaking ? (
<div className="rounded border border-cyan-400/30 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-100">
Speaking
</div>
) : null}
</div>
<div className="mt-4 space-y-4">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Current task
</div>
<div className="mt-1 text-sm leading-6 text-white/85">
{card.currentTask}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Recent commits
</div>
<div className="mt-2 space-y-2">
{card.recentCommits.length === 0 ? (
<div className="font-mono text-[11px] text-white/35">
No recent GitHub activity.
</div>
) : (
card.recentCommits.map((commit) => (
<div
key={commit.id}
className="rounded border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm text-white/82">{commit.title}</div>
{commit.subtitle ? (
<div className="mt-1 font-mono text-[10px] text-white/40">
{commit.subtitle}
</div>
) : null}
</div>
))
)}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Active tickets
</div>
<div className="mt-2 space-y-2">
{card.activeTickets.length === 0 ? (
<div className="font-mono text-[11px] text-white/35">
No active Jira tickets.
</div>
) : (
card.activeTickets.map((ticket) => (
<div
key={ticket.id}
className="rounded border border-white/8 bg-black/20 px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/80">
{ticket.key}
</div>
<div className="mt-1 text-sm text-white/82">
{ticket.title}
</div>
<div className="mt-1 font-mono text-[10px] text-white/40">
{ticket.status}
</div>
</div>
{ticket.url ? (
<a
href={ticket.url}
target="_blank"
rel="noreferrer"
className="text-white/45 transition-colors hover:text-white"
>
<ExternalLink className="h-4 w-4" />
</a>
) : null}
</div>
</div>
))
)}
</div>
</div>
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Blockers
</div>
<div className="mt-2 space-y-2">
{card.blockers.length === 0 ? (
<div className="font-mono text-[11px] text-emerald-200/75">
No blockers reported.
</div>
) : (
card.blockers.map((blocker, index) => (
<div
key={`${card.agentId}-blocker-${index}`}
className="rounded border border-rose-400/20 bg-rose-500/[0.06] px-3 py-2 text-sm text-rose-100/90"
>
{blocker}
</div>
))
)}
</div>
</div>
{card.manualNotes.length > 0 ? (
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Manual notes
</div>
<div className="mt-2 space-y-2">
{card.manualNotes.map((note, index) => (
<div
key={`${card.agentId}-note-${index}`}
className="rounded border border-white/8 bg-black/20 px-3 py-2 text-sm text-white/75"
>
{note}
</div>
))}
</div>
</div>
) : null}
<div>
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
Sources
</div>
<div className="mt-2 flex flex-wrap gap-2">
{card.sourceStates.map((source) => (
<div
key={`${card.agentId}-${source.kind}`}
className={`rounded border px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] ${sourceTone(
source.ready,
source.stale
)}`}
>
{source.kind}
{source.error ? ` · ${source.error}` : source.stale ? " · stale" : ""}
</div>
))}
</div>
</div>
</div>
</section>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,254 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { X } from "lucide-react";
import type { GitHubInlineCommentSide } from "@/lib/office/github";
import {
getDiffLineTone,
parseDiffPatch,
type GitHubDiffFile,
} from "./diff";
import { maskGitHubRecordingText } from "./utils";
type FileDiffModalProps = {
file: GitHubDiffFile;
repo: string;
pullNumber: number;
commitId: string | null;
onSubmitInlineComment: (input: {
repo: string;
pullNumber: number;
commitId: string | null;
path: string;
line: number;
side: GitHubInlineCommentSide;
body: string;
}) => Promise<void>;
onClose: () => void;
};
export function FileDiffModal({
file,
repo,
pullNumber,
commitId,
onSubmitInlineComment,
onClose,
}: FileDiffModalProps) {
const diffLines = useMemo(() => parseDiffPatch(file.patch), [file.patch]);
const [selectedLineId, setSelectedLineId] = useState<string | null>(null);
const [commentBody, setCommentBody] = useState("");
const [commentBusy, setCommentBusy] = useState(false);
const [commentError, setCommentError] = useState<string | null>(null);
const [submissionMessage, setSubmissionMessage] = useState<string | null>(null);
const [submissionTone, setSubmissionTone] = useState<"info" | "success" | "error">(
"info",
);
const selectedLine = useMemo(
() =>
diffLines.find((line) => line.id === selectedLineId && line.commentable) ??
null,
[diffLines, selectedLineId],
);
const handleSubmitComment = useCallback(() => {
if (!selectedLine?.side || !selectedLine.lineNumber) return;
const nextCommentBody = commentBody;
const nextLineNumber = selectedLine.lineNumber;
const nextSide = selectedLine.side;
setCommentBusy(true);
setCommentError(null);
setSubmissionTone("info");
setSubmissionMessage("Submitting inline comment...");
setCommentBody("");
setSelectedLineId(null);
void onSubmitInlineComment({
repo,
pullNumber,
commitId,
path: file.path,
line: nextLineNumber,
side: nextSide,
body: nextCommentBody,
})
.then(() => {
setSubmissionTone("success");
setSubmissionMessage("Inline comment submitted.");
})
.catch((error) => {
setSubmissionTone("error");
setSubmissionMessage(
error instanceof Error
? error.message
: "Unable to submit the inline comment.",
);
})
.finally(() => {
setCommentBusy(false);
});
}, [
commentBody,
commitId,
file.path,
onSubmitInlineComment,
pullNumber,
repo,
selectedLine,
]);
return (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-black/76 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
className="flex h-full max-h-[92%] w-full max-w-6xl flex-col overflow-hidden rounded-[28px] border border-cyan-300/16 bg-[#081223] shadow-[0_28px_120px_rgba(0,0,0,0.66)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-white/8 px-6 py-5">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-[0.24em] text-cyan-100/48">
File Diff
</div>
<div className="mt-2 break-all text-lg font-semibold text-white">
{file.path}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-white/50">
{file.status ? (
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">
{file.status}
</span>
) : null}
<span className="rounded-full border border-emerald-400/18 bg-emerald-400/10 px-2.5 py-1 text-emerald-100/88">
+{file.additions}
</span>
<span className="rounded-full border border-rose-400/18 bg-rose-400/10 px-2.5 py-1 text-rose-100/88">
-{file.deletions}
</span>
</div>
{file.previousPath ? (
<div className="mt-3 text-sm text-white/54">
Renamed from {file.previousPath}
</div>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-white/5 text-white/70 transition-colors hover:border-white/18 hover:text-white"
aria-label="Close file diff"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className="overflow-hidden rounded-2xl border border-white/8 bg-[#050b15]">
<div className="border-b border-white/8 px-4 py-3 text-[11px] uppercase tracking-[0.22em] text-white/40">
Patch
</div>
{submissionMessage ? (
<div
className={`border-b px-4 py-3 text-sm ${
submissionTone === "success"
? "border-emerald-400/18 bg-emerald-400/10 text-emerald-100"
: submissionTone === "error"
? "border-rose-400/18 bg-rose-400/10 text-rose-100"
: "border-cyan-300/14 bg-cyan-300/10 text-cyan-100"
}`}
>
{submissionMessage}
</div>
) : null}
<div className="overflow-auto p-2">
{diffLines.map((line) => (
<div key={line.id}>
{line.commentable ? (
<button
type="button"
onClick={() => {
setSelectedLineId(line.id);
setCommentError(null);
}}
className={`grid w-full grid-cols-[44px_44px_minmax(0,1fr)] gap-3 rounded-md px-3 py-1 text-left transition-colors hover:bg-white/6 ${
selectedLine?.id === line.id ? "ring-1 ring-cyan-300/28" : ""
} ${getDiffLineTone(line.text)}`}
>
<span className="select-none text-right font-mono text-[11px] text-white/28">
{line.oldNumber ?? ""}
</span>
<span className="select-none text-right font-mono text-[11px] text-white/28">
{line.newNumber ?? ""}
</span>
<span className="block whitespace-pre-wrap break-all font-mono text-[12px] leading-5">
{maskGitHubRecordingText(line.text) || " "}
</span>
</button>
) : (
<div
className={`grid grid-cols-[44px_44px_minmax(0,1fr)] gap-3 rounded-md px-3 py-1 ${getDiffLineTone(line.text)}`}
>
<span className="select-none text-right font-mono text-[11px] text-white/22" />
<span className="select-none text-right font-mono text-[11px] text-white/22" />
<span className="block whitespace-pre-wrap break-all font-mono text-[12px] leading-5">
{maskGitHubRecordingText(line.text) || " "}
</span>
</div>
)}
{selectedLine?.id === line.id ? (
<div className="mx-3 my-2 rounded-2xl border border-cyan-300/14 bg-[#0b172c] p-4">
<div className="text-[11px] uppercase tracking-[0.18em] text-cyan-100/58">
Comment on {(selectedLine.side ?? "RIGHT").toLowerCase()} side
line {selectedLine.lineNumber}
</div>
<textarea
value={commentBody}
onChange={(event) => setCommentBody(event.target.value)}
placeholder="Add an inline comment."
className="mt-3 h-28 w-full resize-none rounded-2xl border border-white/8 bg-black/22 px-4 py-3 text-sm text-white outline-none placeholder:text-white/28"
/>
{commentError ? (
<div className="mt-3 rounded-xl border border-rose-400/18 bg-rose-400/10 px-3 py-2 text-sm text-rose-100">
{commentError}
</div>
) : null}
<div className="mt-3 flex items-center justify-between gap-3">
<div className="text-[12px] text-white/45">
This posts directly to GitHub on the selected diff line.
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setSelectedLineId(null);
setCommentBody("");
setCommentError(null);
}}
className="inline-flex items-center rounded-full border border-white/10 bg-white/5 px-3 py-2 text-[12px] text-white/68 transition-colors hover:border-white/18 hover:text-white"
>
Cancel
</button>
<button
type="button"
disabled={commentBusy || !commentBody.trim()}
onClick={() => void handleSubmitComment()}
className="inline-flex items-center rounded-full border border-cyan-300/20 bg-cyan-300/10 px-3 py-2 text-[12px] text-cyan-100 transition-colors hover:border-cyan-200/38 hover:bg-cyan-300/16 disabled:cursor-not-allowed disabled:opacity-60"
>
{commentBusy ? "Submitting..." : "Add Comment"}
</button>
</div>
</div>
</div>
) : null}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import type {
GitHubInlineCommentSide,
GitHubPullRequestDetail,
} from "@/lib/office/github";
export type GitHubDiffFile = GitHubPullRequestDetail["files"][number];
export type ParsedDiffLine = {
id: string;
text: string;
kind: "meta" | "hunk" | "context" | "add" | "del";
oldNumber: number | null;
newNumber: number | null;
lineNumber: number | null;
side: GitHubInlineCommentSide | null;
commentable: boolean;
};
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
export const getDiffLineTone = (line: string): string => {
if (line.startsWith("@@")) {
return "bg-cyan-400/10 text-cyan-100";
}
if (line.startsWith("+") && !line.startsWith("+++")) {
return "bg-emerald-500/12 text-emerald-100";
}
if (line.startsWith("-") && !line.startsWith("---")) {
return "bg-rose-500/12 text-rose-100";
}
if (
line.startsWith("diff --git") ||
line.startsWith("index ") ||
line.startsWith("---") ||
line.startsWith("+++")
) {
return "bg-white/4 text-white/72";
}
return "text-white/78";
};
export const parseDiffPatch = (patch: string | null): ParsedDiffLine[] => {
const lines = (patch ?? "Diff preview unavailable for this file.").split("\n");
const parsed: ParsedDiffLine[] = [];
let oldLine = 0;
let newLine = 0;
let inHunk = false;
lines.forEach((line, index) => {
const hunkMatch = line.match(HUNK_HEADER_PATTERN);
if (hunkMatch) {
oldLine = Number(hunkMatch[1]);
newLine = Number(hunkMatch[2]);
inHunk = true;
parsed.push({
id: `hunk-${index}`,
text: line,
kind: "hunk",
oldNumber: null,
newNumber: null,
lineNumber: null,
side: null,
commentable: false,
});
return;
}
if (!inHunk) {
parsed.push({
id: `meta-${index}`,
text: line,
kind: "meta",
oldNumber: null,
newNumber: null,
lineNumber: null,
side: null,
commentable: false,
});
return;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
parsed.push({
id: `add-${index}`,
text: line,
kind: "add",
oldNumber: null,
newNumber: newLine,
lineNumber: newLine,
side: "RIGHT",
commentable: true,
});
newLine += 1;
return;
}
if (line.startsWith("-") && !line.startsWith("---")) {
parsed.push({
id: `del-${index}`,
text: line,
kind: "del",
oldNumber: oldLine,
newNumber: null,
lineNumber: oldLine,
side: "LEFT",
commentable: true,
});
oldLine += 1;
return;
}
if (line.startsWith("\\")) {
parsed.push({
id: `meta-${index}`,
text: line,
kind: "meta",
oldNumber: null,
newNumber: null,
lineNumber: null,
side: null,
commentable: false,
});
return;
}
parsed.push({
id: `ctx-${index}`,
text: line,
kind: "context",
oldNumber: oldLine,
newNumber: newLine,
lineNumber: newLine,
side: "RIGHT",
commentable: true,
});
oldLine += 1;
newLine += 1;
});
return parsed;
};
@@ -0,0 +1,87 @@
"use client";
import { useEffect, useRef, useState } from "react";
type BrowserPreviewState = {
mediaUrl: string | null;
browserUrl: string | null;
loading: boolean;
error: string | null;
};
export function useBrowserPreview(url: string | null, enabled: boolean) {
const [state, setState] = useState<BrowserPreviewState>({
mediaUrl: null,
browserUrl: url,
loading: false,
error: null,
});
const requestIdRef = useRef(0);
useEffect(() => {
if (!enabled || !url) {
requestIdRef.current += 1;
setState({
mediaUrl: null,
browserUrl: url,
loading: false,
error: null,
});
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setState((current) => ({
...current,
browserUrl: url,
loading: true,
error: null,
}));
void (async () => {
try {
const params = new URLSearchParams({
url,
ts: String(Date.now()),
});
const response = await fetch(
`/api/office/browser-preview?${params.toString()}`,
{
cache: "no-store",
},
);
const payload = (await response.json()) as {
error?: string;
mediaUrl?: string;
browserUrl?: string;
};
if (!response.ok) {
throw new Error(
payload.error?.trim() || "Unable to capture GitHub browser preview.",
);
}
if (requestIdRef.current !== requestId) return;
setState({
mediaUrl: payload.mediaUrl?.trim() || null,
browserUrl: payload.browserUrl?.trim() || url,
loading: false,
error: null,
});
} catch (error) {
if (requestIdRef.current !== requestId) return;
setState({
mediaUrl: null,
browserUrl: url,
loading: false,
error:
error instanceof Error
? error.message
: "Unable to capture GitHub browser preview.",
});
}
})();
}, [enabled, url]);
return state;
}
@@ -0,0 +1,29 @@
"use client";
export const GITHUB_RECORDING_PRIVACY_MASK_ACTIVE = false;
export const maskGitHubRecordingText = (
value: string | null | undefined,
): string => {
return value ?? "";
};
export const formatRelativeTime = (value: string | null): string => {
if (!value) return "Unknown update";
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return value;
const deltaMinutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (deltaMinutes < 1) return "Updated just now";
if (deltaMinutes < 60) return `Updated ${deltaMinutes}m ago`;
const deltaHours = Math.round(deltaMinutes / 60);
if (deltaHours < 24) return `Updated ${deltaHours}h ago`;
const deltaDays = Math.round(deltaHours / 24);
return `Updated ${deltaDays}d ago`;
};
export const summarizeChecksTone = (summary: string | null): string => {
if (!summary) return "text-white/45";
if (summary.includes("failing")) return "text-rose-300";
if (summary.includes("pending")) return "text-amber-200";
return "text-emerald-200";
};