First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
Reference in New Issue
Block a user