mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat: refine mobile adaptation and pixel office viewport behavior
This commit is contained in:
+4
-4
@@ -221,8 +221,8 @@ export default function AlertsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-4xl mx-auto">
|
||||
<div className="flex flex-col gap-3 mb-6 md:mb-8 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
🔔 {t("alerts.title") || "Alert Center"}
|
||||
@@ -231,10 +231,10 @@ export default function AlertsPage() {
|
||||
{t("alerts.subtitle") || "Configure system alerts and notifications"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 检查间隔设置 */}
|
||||
{config.enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-[var(--text-muted)]">{t("alerts.checkInterval") || "Check Interval"}:</span>
|
||||
<select
|
||||
value={checkInterval}
|
||||
|
||||
+20
-7
@@ -19,7 +19,13 @@ interface HealthResult {
|
||||
webUrl?: string;
|
||||
}
|
||||
|
||||
export function GatewayStatus() {
|
||||
interface GatewayStatusProps {
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
hideIconOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export function GatewayStatus({ compact = false, className = "", hideIconOnMobile = false }: GatewayStatusProps) {
|
||||
const { t } = useI18n();
|
||||
const [health, setHealth] = useState<HealthResult | null>(null);
|
||||
const [showError, setShowError] = useState(false);
|
||||
@@ -38,27 +44,34 @@ export function GatewayStatus() {
|
||||
}, [check]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center gap-2">
|
||||
<div className={`relative inline-flex items-center gap-1.5 ${className}`.trim()}>
|
||||
<a
|
||||
href={health?.ok && health.webUrl ? resolveGatewayUrl(health.webUrl) : undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border hover:bg-cyan-500/30 transition-colors cursor-pointer ${
|
||||
className={`inline-flex items-center rounded-full font-medium border hover:bg-cyan-500/30 transition-colors cursor-pointer ${
|
||||
compact ? "px-2 py-1 text-[10px]" : "px-2 py-0.5 text-xs"
|
||||
} ${
|
||||
health?.ok
|
||||
? "bg-cyan-500/25 text-cyan-200 border-cyan-400/45 animate-pulse"
|
||||
: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30"
|
||||
}`}
|
||||
>
|
||||
🦞 Gateway
|
||||
{compact ? "GW" : hideIconOnMobile ? (
|
||||
<>
|
||||
<span className="md:hidden">Gateway</span>
|
||||
<span className="hidden md:inline">🦞 Gateway</span>
|
||||
</>
|
||||
) : "🦞 Gateway"}
|
||||
<span className="opacity-50 text-[10px]">↗</span>
|
||||
</a>
|
||||
{!health ? (
|
||||
<span className="text-xs text-[var(--text-muted)]">--</span>
|
||||
<span className={compact ? "text-[10px] text-[var(--text-muted)]" : "text-xs text-[var(--text-muted)]"}>--</span>
|
||||
) : health.ok ? (
|
||||
<span className="text-green-400 text-sm cursor-help" title={t("gateway.healthy")}>✅</span>
|
||||
<span className={compact ? "text-green-400 text-xs cursor-help" : "text-green-400 text-sm cursor-help"} title={t("gateway.healthy")}>✅</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-red-400 text-sm cursor-pointer"
|
||||
className={compact ? "text-red-400 text-xs cursor-pointer" : "text-red-400 text-sm cursor-pointer"}
|
||||
title={health.error || t("gateway.unhealthy")}
|
||||
onClick={() => setShowError((v) => !v)}
|
||||
>❌</span>
|
||||
|
||||
@@ -116,7 +116,6 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
|
||||
+8
-3
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { Sidebar } from "./sidebar";
|
||||
@@ -10,6 +10,11 @@ export const metadata: Metadata = {
|
||||
description: "查看所有 OpenClaw 机器人配置",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
@@ -17,9 +22,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<Providers>
|
||||
<AlertMonitor />
|
||||
<GlobalBugsOverlay />
|
||||
<div className="flex min-h-screen">
|
||||
<div className="min-h-screen md:flex">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<main className="flex-1 overflow-auto pt-14 md:pt-0">{children}</main>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
|
||||
+82
-6
@@ -203,8 +203,8 @@ export default function ModelsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
<div className="flex flex-col gap-3 mb-6 md:mb-8 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
{t("models.title")}
|
||||
@@ -213,7 +213,7 @@ export default function ModelsPage() {
|
||||
{t("models.totalPrefix")} {data.providers.length} {t("models.providerCount")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={testAllModels}
|
||||
disabled={Object.values(testing).some(Boolean)}
|
||||
@@ -260,7 +260,7 @@ export default function ModelsPage() {
|
||||
key={provider.id}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-5"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{provider.id}</h2>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
@@ -280,10 +280,84 @@ export default function ModelsPage() {
|
||||
</div>
|
||||
|
||||
{provider.models.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div>
|
||||
{(() => {
|
||||
const hasDetail = provider.models.some((m: any) => m.contextWindow || m.maxTokens);
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden space-y-2">
|
||||
{provider.models.map((m) => {
|
||||
const stat = modelStats[`${provider.id}/${m.id}`];
|
||||
const testKey = `${provider.id}/${m.id}`;
|
||||
const isTesting = testing[testKey];
|
||||
const result = testResults[testKey];
|
||||
return (
|
||||
<div key={m.id} className="rounded-lg border border-[var(--border)] bg-[var(--bg)] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs text-[var(--accent)] truncate">{m.id}</div>
|
||||
<div className="text-sm text-[var(--text)] truncate">{m.name || "-"}</div>
|
||||
</div>
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-[var(--card)] text-[10px] border border-[var(--border)]">
|
||||
{provider.accessMode === "auth" ? t("models.accessModeAuth") : t("models.accessModeApiKey")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded border border-[var(--border)] bg-[var(--card)] px-2 py-1">
|
||||
<div className="text-[var(--text-muted)]">{t("models.colInputToken")}</div>
|
||||
<div className="text-blue-400 font-mono">{stat ? formatTokens(stat.inputTokens) : "-"}</div>
|
||||
</div>
|
||||
<div className="rounded border border-[var(--border)] bg-[var(--card)] px-2 py-1">
|
||||
<div className="text-[var(--text-muted)]">{t("models.colOutputToken")}</div>
|
||||
<div className="text-emerald-400 font-mono">{stat ? formatTokens(stat.outputTokens) : "-"}</div>
|
||||
</div>
|
||||
<div className="rounded border border-[var(--border)] bg-[var(--card)] px-2 py-1">
|
||||
<div className="text-[var(--text-muted)]">{t("models.colAvgResponse")}</div>
|
||||
<div className="text-amber-400 font-mono">{stat ? formatMs(stat.avgResponseMs) : "-"}</div>
|
||||
</div>
|
||||
{hasDetail && (
|
||||
<div className="rounded border border-[var(--border)] bg-[var(--card)] px-2 py-1">
|
||||
<div className="text-[var(--text-muted)]">{t("models.colContext")}</div>
|
||||
<div className="text-[var(--text)] font-mono">{formatNum(m.contextWindow || 0)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasDetail && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{(m.input || []).map((inputType) => (
|
||||
<span key={inputType} className="px-1.5 py-0.5 rounded bg-[var(--card)] text-[10px]">
|
||||
{inputType === "text" ? "📝" : "🖼️"} {inputType}
|
||||
</span>
|
||||
))}
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--card)] text-[10px]">
|
||||
{t("models.colReasoning")}: {m.reasoning ? "✅" : "❌"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => testModel(provider.id, m.id)}
|
||||
disabled={isTesting}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition ${
|
||||
isTesting
|
||||
? "bg-gray-500/20 text-gray-400 cursor-wait"
|
||||
: "bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/30 hover:bg-[var(--accent)]/40 cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{isTesting ? t("common.testing") : t("common.test")}
|
||||
</button>
|
||||
{result && (
|
||||
<span className={`text-[10px] ${result.ok ? "text-green-400" : "text-red-400"} truncate max-w-[56vw]`} title={result.ok ? result.text : result.error}>
|
||||
{result.ok ? `✅ ${formatMs(result.elapsed)}` : `❌ ${result.error?.slice(0, 42)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--text-muted)] text-xs border-b border-[var(--border)]">
|
||||
@@ -358,7 +432,9 @@ export default function ModelsPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+19
-17
@@ -357,7 +357,7 @@ function ModelBadge({ model, accessMode }: { model: string; accessMode?: "auth"
|
||||
colors[provider] || "bg-gray-500/20 text-gray-300 border-gray-500/30"
|
||||
}`}
|
||||
>
|
||||
🧠 {modelName}{accessMode ? ` (${accessMode})` : ""}
|
||||
{modelName}{accessMode ? ` (${accessMode})` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -921,10 +921,10 @@ export default function Home() {
|
||||
providerAccessModeMap[p.id] = p.accessMode;
|
||||
}
|
||||
return (
|
||||
<div className="p-3 max-w-6xl mx-auto">
|
||||
<div className="p-3 md:p-4 max-w-6xl mx-auto">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between mb-2 gap-2">
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 mb-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="hidden md:block">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
🤖 {t("home.pageTitle")}
|
||||
</h1>
|
||||
@@ -932,44 +932,46 @@ export default function Home() {
|
||||
{t("models.totalPrefix")} {data.agents.length} {t("home.agentCount")} · {t("home.defaultModel")}: {data.defaults.model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 max-w-full">
|
||||
<button
|
||||
onClick={testAllAgents}
|
||||
disabled={testing}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--bg)] text-sm font-medium hover:opacity-90 transition disabled:opacity-50 cursor-pointer"
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{testing ? t("home.testingAll") : t("home.testAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={testAllPlatforms}
|
||||
disabled={testingPlatforms}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{testingPlatforms ? t("home.testingPlatforms") : t("home.testPlatforms")}
|
||||
</button>
|
||||
<button
|
||||
onClick={testAllSessions}
|
||||
disabled={testingSessions}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{testingSessions ? t("home.testingSessions") : t("home.testSessions")}
|
||||
</button>
|
||||
<button
|
||||
onClick={testAllDmSessions}
|
||||
disabled={testingDmSessions}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{testingDmSessions ? t("home.testingDmSessions") : t("home.testDmSessions")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<GatewayStatus />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="shrink-0">
|
||||
<GatewayStatus hideIconOnMobile />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0 max-w-full overflow-x-auto pb-1 md:overflow-visible md:pb-0">
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm text-[var(--text)] cursor-pointer hover:border-[var(--accent)] transition"
|
||||
className="shrink-0 px-3 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm text-[var(--text)] cursor-pointer hover:border-[var(--accent)] transition"
|
||||
>
|
||||
{REFRESH_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -981,13 +983,13 @@ export default function Home() {
|
||||
<button
|
||||
onClick={() => fetchData(false)}
|
||||
disabled={loading}
|
||||
className="px-3 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition disabled:opacity-50"
|
||||
className="shrink-0 px-3 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
<span className="shrink-0 text-xs text-[var(--text-muted)] whitespace-nowrap">
|
||||
{t("home.updatedAt")} {lastUpdated}
|
||||
</span>
|
||||
)}
|
||||
@@ -1004,7 +1006,7 @@ export default function Home() {
|
||||
{/* 汇总统计趋势 */}
|
||||
{allStats && (
|
||||
<div className="mt-8 p-5 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col gap-2 mb-4 md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">{t("home.globalTrend")}</h2>
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
|
||||
@@ -1021,7 +1023,7 @@ export default function Home() {
|
||||
const totalMsgs = currentData.reduce((s, d) => s + d.messageCount, 0);
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4">
|
||||
<div className="p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
|
||||
<div className="text-[10px] text-[var(--text-muted)]">{t("home.totalInputToken")}</div>
|
||||
<div className="text-lg font-bold text-blue-400">{formatTokens(totalInput)}</div>
|
||||
|
||||
+265
-108
@@ -87,11 +87,11 @@ function MiniSparkline({ data, width = 120, height = 24, color: fixedColor }: {
|
||||
|
||||
/** Convert mouse event to tile coordinates */
|
||||
function mouseToTile(
|
||||
e: React.MouseEvent, canvas: HTMLCanvasElement, office: OfficeState, zoom: number, pan: { x: number; y: number }
|
||||
clientX: number, clientY: number, canvas: HTMLCanvasElement, office: OfficeState, zoom: number, pan: { x: number; y: number }
|
||||
): { col: number; row: number; worldX: number; worldY: number } {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
const x = clientX - rect.left
|
||||
const y = clientY - rect.top
|
||||
const cols = office.layout.cols
|
||||
const rows = office.layout.rows
|
||||
const mapW = cols * TILE_SIZE * zoom
|
||||
@@ -114,8 +114,53 @@ function getGhostBorderDirection(col: number, row: number, cols: number, rows: n
|
||||
return null
|
||||
}
|
||||
|
||||
const FIXED_CANVAS_ZOOM = 2.5
|
||||
function getLayoutContentBounds(layout: OfficeLayout): { minCol: number; maxCol: number; minRow: number; maxRow: number } {
|
||||
let minCol = layout.cols - 1
|
||||
let maxCol = 0
|
||||
let minRow = layout.rows - 1
|
||||
let maxRow = 0
|
||||
let hasContent = false
|
||||
|
||||
for (let r = 0; r < layout.rows; r++) {
|
||||
for (let c = 0; c < layout.cols; c++) {
|
||||
if (layout.tiles[r * layout.cols + c] === TileType.VOID) continue
|
||||
hasContent = true
|
||||
if (c < minCol) minCol = c
|
||||
if (c > maxCol) maxCol = c
|
||||
if (r < minRow) minRow = r
|
||||
if (r > maxRow) maxRow = r
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of layout.furniture) {
|
||||
const entry = getCatalogEntry(f.type)
|
||||
const w = Math.max(1, entry?.footprintW ?? 1)
|
||||
const h = Math.max(1, entry?.footprintH ?? 1)
|
||||
hasContent = true
|
||||
if (f.col < minCol) minCol = f.col
|
||||
if (f.col + w - 1 > maxCol) maxCol = f.col + w - 1
|
||||
if (f.row < minRow) minRow = f.row
|
||||
if (f.row + h - 1 > maxRow) maxRow = f.row + h - 1
|
||||
}
|
||||
|
||||
if (!hasContent) return { minCol: 0, maxCol: layout.cols - 1, minRow: 0, maxRow: layout.rows - 1 }
|
||||
return {
|
||||
minCol: Math.max(0, minCol),
|
||||
maxCol: Math.min(layout.cols - 1, maxCol),
|
||||
minRow: Math.max(0, minRow),
|
||||
maxRow: Math.min(layout.rows - 1, maxRow),
|
||||
}
|
||||
}
|
||||
|
||||
const DESKTOP_CANVAS_ZOOM = 2.5
|
||||
const MOBILE_CANVAS_ZOOM = 1.9
|
||||
const MOBILE_MIN_ZOOM = 0.55
|
||||
const MOBILE_MAX_ZOOM = 6
|
||||
const MOBILE_FIT_PADDING_PX = 2
|
||||
const MOBILE_TOP_EXTRA_TILES = 0.5
|
||||
const CODE_SNIPPET_LIFETIME_SEC = 5.5
|
||||
const FLOATING_TICK_INTERVAL_DESKTOP_MS = 48
|
||||
const FLOATING_TICK_INTERVAL_MOBILE_MS = 140
|
||||
|
||||
let cachedOfficeState: OfficeState | null = null
|
||||
let cachedEditorState: EditorState | null = null
|
||||
@@ -136,7 +181,7 @@ export default function PixelOfficePage() {
|
||||
const editorRef = useRef<EditorState>(cachedEditorState ?? new EditorState())
|
||||
const agentIdMapRef = useRef<Map<string, number>>(new Map(cachedAgentIdMap))
|
||||
const nextIdRef = useRef<{ current: number }>({ current: cachedNextCharacterId })
|
||||
const zoomRef = useRef<number>(FIXED_CANVAS_ZOOM)
|
||||
const zoomRef = useRef<number>(DESKTOP_CANVAS_ZOOM)
|
||||
const panRef = useRef<{ x: number; y: number }>(cachedPan)
|
||||
const savedLayoutRef = useRef<OfficeLayout | null>(cachedSavedLayout)
|
||||
const animationFrameIdRef = useRef<number | null>(null)
|
||||
@@ -169,7 +214,9 @@ export default function PixelOfficePage() {
|
||||
const idleRankRef = useRef<Array<{ agentId: string; onlineMinutes: number; activeMinutes: number; idleMinutes: number; idlePercent: number }> | null>(null)
|
||||
const floatingCommentsRef = useRef<Array<{ key: string; text: string; x: number; y: number; opacity: number }>>([])
|
||||
const floatingCodeRef = useRef<Array<{ key: string; text: string; x: number; y: number; opacity: number }>>([])
|
||||
const floatingTickUpdatedAtRef = useRef<number>(0)
|
||||
const [floatingTick, setFloatingTick] = useState(0)
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false)
|
||||
const forceEditorUpdate = useCallback(() => setEditorTick(t => t + 1), [])
|
||||
|
||||
const fetchVersionInfo = useCallback(async (forceLatest = false) => {
|
||||
@@ -189,6 +236,14 @@ export default function PixelOfficePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 767px)")
|
||||
const apply = () => setIsMobileViewport(mql.matches)
|
||||
apply()
|
||||
mql.addEventListener("change", apply)
|
||||
return () => mql.removeEventListener("change", apply)
|
||||
}, [])
|
||||
|
||||
// Load saved layout and sound preference
|
||||
useEffect(() => {
|
||||
const loadLayout = async () => {
|
||||
@@ -283,8 +338,32 @@ export default function PixelOfficePage() {
|
||||
const width = container.clientWidth
|
||||
const height = container.clientHeight
|
||||
|
||||
// Fixed zoom: disable runtime zooming for now
|
||||
zoomRef.current = FIXED_CANVAS_ZOOM
|
||||
// Keep desktop zoom fixed. On mobile, fit the whole office into current viewport.
|
||||
if (isMobileViewport) {
|
||||
const layout = office.layout
|
||||
const rows = layout.rows
|
||||
const cols = layout.cols
|
||||
const baseW = cols * TILE_SIZE
|
||||
const topExtraTiles = MOBILE_TOP_EXTRA_TILES
|
||||
const fitW = Math.max(1, width - MOBILE_FIT_PADDING_PX * 2) / Math.max(1, baseW)
|
||||
const fitH = Math.max(1, height - MOBILE_FIT_PADDING_PX * 2) / Math.max(1, (rows + topExtraTiles) * TILE_SIZE)
|
||||
const fitZoom = Math.min(fitW, fitH)
|
||||
const nextZoom = Math.max(MOBILE_MIN_ZOOM, Math.min(MOBILE_MAX_ZOOM, fitZoom || MOBILE_CANVAS_ZOOM))
|
||||
zoomRef.current = nextZoom
|
||||
|
||||
const mapH = rows * TILE_SIZE * nextZoom
|
||||
const centerOffsetY = (height - mapH) / 2
|
||||
const topExtraPx = topExtraTiles * TILE_SIZE * nextZoom
|
||||
const minPanY = MOBILE_FIT_PADDING_PX + topExtraPx - centerOffsetY
|
||||
const maxPanY = height - MOBILE_FIT_PADDING_PX - (centerOffsetY + mapH)
|
||||
const targetPanY = minPanY > maxPanY ? minPanY : Math.min(maxPanY, Math.max(minPanY, 0))
|
||||
panRef.current = { x: 0, y: Math.round(targetPanY) }
|
||||
} else {
|
||||
zoomRef.current = DESKTOP_CANVAS_ZOOM
|
||||
if (panRef.current.x !== 0 || panRef.current.y !== 0) {
|
||||
panRef.current = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
office.update(dt)
|
||||
|
||||
@@ -354,50 +433,57 @@ export default function PixelOfficePage() {
|
||||
const cid = agentIdMapRef.current.get(a.agentId)
|
||||
if (typeof cid === 'number') workingCharIds.add(cid)
|
||||
}
|
||||
for (const ch of office.getCharacters()) {
|
||||
if (ch.photoComments.length === 0) continue
|
||||
const anchorX = ox + ch.x * zoom
|
||||
const anchorY = containerTop + oy + (ch.y - 24) * zoom
|
||||
const totalDist = anchorY + 20
|
||||
for (let i = 0; i < ch.photoComments.length; i++) {
|
||||
const pc = ch.photoComments[i]
|
||||
const progress = pc.age / lifetime
|
||||
let alpha = 1.0
|
||||
if (pc.age < 0.3) alpha = pc.age / 0.3
|
||||
if (progress > 0.6) alpha = (1 - progress) / 0.4
|
||||
const floatY = progress * totalDist
|
||||
items.push({
|
||||
key: `${ch.id}-${i}-${pc.text}`,
|
||||
text: pc.text,
|
||||
x: anchorX + pc.x * zoom,
|
||||
y: anchorY - floatY,
|
||||
opacity: Math.max(0, alpha * 0.95),
|
||||
})
|
||||
if (!isMobileViewport) {
|
||||
for (const ch of office.getCharacters()) {
|
||||
if (ch.photoComments.length === 0) continue
|
||||
const anchorX = ox + ch.x * zoom
|
||||
const anchorY = containerTop + oy + (ch.y - 24) * zoom
|
||||
const totalDist = anchorY + 20
|
||||
for (let i = 0; i < ch.photoComments.length; i++) {
|
||||
const pc = ch.photoComments[i]
|
||||
const progress = pc.age / lifetime
|
||||
let alpha = 1.0
|
||||
if (pc.age < 0.3) alpha = pc.age / 0.3
|
||||
if (progress > 0.6) alpha = (1 - progress) / 0.4
|
||||
const floatY = progress * totalDist
|
||||
items.push({
|
||||
key: `${ch.id}-${i}-${pc.text}`,
|
||||
text: pc.text,
|
||||
x: anchorX + pc.x * zoom,
|
||||
y: anchorY - floatY,
|
||||
opacity: Math.max(0, alpha * 0.95),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of office.getCharacters()) {
|
||||
if (!workingCharIds.has(ch.id)) continue
|
||||
if (ch.codeSnippets.length === 0) continue
|
||||
const anchorX = ox + ch.x * zoom
|
||||
const anchorY = containerTop + oy + (ch.y - 10) * zoom
|
||||
const totalDist = anchorY + 24
|
||||
for (let i = 0; i < ch.codeSnippets.length; i++) {
|
||||
const s = ch.codeSnippets[i]
|
||||
const progress = s.age / CODE_SNIPPET_LIFETIME_SEC
|
||||
if (progress <= 0 || progress >= 1) continue
|
||||
const alpha = progress < 0.15 ? progress / 0.15 : progress > 0.88 ? (1 - progress) / 0.12 : 1
|
||||
codeItems.push({
|
||||
key: `${ch.id}-code-${i}-${s.text}`,
|
||||
text: s.text,
|
||||
x: anchorX + s.x * zoom,
|
||||
y: anchorY - progress * totalDist,
|
||||
opacity: Math.max(0, alpha * 0.9),
|
||||
})
|
||||
for (const ch of office.getCharacters()) {
|
||||
if (!workingCharIds.has(ch.id)) continue
|
||||
if (ch.codeSnippets.length === 0) continue
|
||||
const anchorX = ox + ch.x * zoom
|
||||
const anchorY = containerTop + oy + (ch.y - 10) * zoom
|
||||
const totalDist = anchorY + 24
|
||||
for (let i = 0; i < ch.codeSnippets.length; i++) {
|
||||
const s = ch.codeSnippets[i]
|
||||
const progress = s.age / CODE_SNIPPET_LIFETIME_SEC
|
||||
if (progress <= 0 || progress >= 1) continue
|
||||
const alpha = progress < 0.15 ? progress / 0.15 : progress > 0.88 ? (1 - progress) / 0.12 : 1
|
||||
codeItems.push({
|
||||
key: `${ch.id}-code-${i}-${s.text}`,
|
||||
text: s.text,
|
||||
x: anchorX + s.x * zoom,
|
||||
y: anchorY - progress * totalDist,
|
||||
opacity: Math.max(0, alpha * 0.9),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
floatingCommentsRef.current = items
|
||||
floatingCodeRef.current = codeItems
|
||||
setFloatingTick(t => t + 1)
|
||||
const now = performance.now()
|
||||
const tickInterval = isMobileViewport ? FLOATING_TICK_INTERVAL_MOBILE_MS : FLOATING_TICK_INTERVAL_DESKTOP_MS
|
||||
if (now - floatingTickUpdatedAtRef.current >= tickInterval) {
|
||||
floatingTickUpdatedAtRef.current = now
|
||||
setFloatingTick(t => t + 1)
|
||||
}
|
||||
}
|
||||
animationFrameIdRef.current = requestAnimationFrame(render)
|
||||
}
|
||||
@@ -405,7 +491,7 @@ export default function PixelOfficePage() {
|
||||
return () => {
|
||||
if (animationFrameIdRef.current !== null) cancelAnimationFrame(animationFrameIdRef.current)
|
||||
}
|
||||
}, [hoveredAgentId, editorTick, officeReady, agents])
|
||||
}, [hoveredAgentId, editorTick, officeReady, agents, isMobileViewport])
|
||||
|
||||
// Load GitHub contribution heatmap data (real → fallback mock)
|
||||
useEffect(() => {
|
||||
@@ -618,7 +704,7 @@ export default function PixelOfficePage() {
|
||||
const editor = editorRef.current
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
mousePosRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
const { col, row, worldX, worldY } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
const { col, row, worldX, worldY } = mouseToTile(e.clientX, e.clientY, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
|
||||
if (editor.isEditMode) {
|
||||
// Update ghost preview
|
||||
@@ -745,7 +831,7 @@ export default function PixelOfficePage() {
|
||||
if (!editor.isEditMode) {
|
||||
// Non-edit mode: check camera click or character click
|
||||
if (e.button === 0) {
|
||||
const { worldX, worldY } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
const { worldX, worldY } = mouseToTile(e.clientX, e.clientY, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
const tileX = worldX / TILE_SIZE
|
||||
const tileY = worldY / TILE_SIZE
|
||||
const clickedCamera = office.layout.furniture.find(f => {
|
||||
@@ -849,7 +935,7 @@ export default function PixelOfficePage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
const { col, row } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
const { col, row } = mouseToTile(e.clientX, e.clientY, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
|
||||
if (e.button === 0) {
|
||||
// Left click
|
||||
@@ -965,12 +1051,31 @@ export default function PixelOfficePage() {
|
||||
e.preventDefault()
|
||||
if (!canvasRef.current || !officeRef.current) return
|
||||
const office = officeRef.current
|
||||
const { col, row } = mouseToTile(e, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
const { col, row } = mouseToTile(e.clientX, e.clientY, canvasRef.current, office, zoomRef.current, panRef.current)
|
||||
if (col >= 0 && col < office.layout.cols && row >= 0 && row < office.layout.rows) {
|
||||
applyEdit(paintTile(office.layout, col, row, TileType.VOID as TileTypeVal))
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.pointerType === 'mouse') return
|
||||
e.preventDefault()
|
||||
handleMouseMove({ clientX: e.clientX, clientY: e.clientY, button: 0 } as React.MouseEvent)
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.pointerType === 'mouse') return
|
||||
e.preventDefault()
|
||||
if (e.currentTarget.setPointerCapture) e.currentTarget.setPointerCapture(e.pointerId)
|
||||
handleMouseDown({ clientX: e.clientX, clientY: e.clientY, button: 0 } as React.MouseEvent)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.pointerType === 'mouse') return
|
||||
e.preventDefault()
|
||||
handleMouseUp()
|
||||
}
|
||||
|
||||
// ── Keyboard events ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -1017,6 +1122,42 @@ export default function PixelOfficePage() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [applyEdit, handleUndo, handleRedo, forceEditorUpdate])
|
||||
|
||||
// Esc closes modal overlays in non-edit mode for keyboard accessibility.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || isEditMode) return
|
||||
if (fullscreenPhoto) {
|
||||
setFullscreenPhoto(false)
|
||||
return
|
||||
}
|
||||
if (showIdleRank) {
|
||||
setShowIdleRank(false)
|
||||
return
|
||||
}
|
||||
if (showPhonePanel) {
|
||||
setShowPhonePanel(false)
|
||||
return
|
||||
}
|
||||
if (showActivityHeatmap) {
|
||||
setShowActivityHeatmap(false)
|
||||
return
|
||||
}
|
||||
if (showTokenRank) {
|
||||
setShowTokenRank(false)
|
||||
return
|
||||
}
|
||||
if (showModelPanel) {
|
||||
setShowModelPanel(false)
|
||||
return
|
||||
}
|
||||
if (selectedAgentId) {
|
||||
setSelectedAgentId(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [fullscreenPhoto, isEditMode, selectedAgentId, showActivityHeatmap, showIdleRank, showModelPanel, showPhonePanel, showTokenRank])
|
||||
|
||||
// ── Editor toolbar callbacks ──────────────────────────────────
|
||||
const handleToolChange = useCallback((tool: EditTool) => {
|
||||
editorRef.current.activeTool = tool
|
||||
@@ -1074,9 +1215,9 @@ export default function PixelOfficePage() {
|
||||
}, [])
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
zoomRef.current = FIXED_CANVAS_ZOOM
|
||||
zoomRef.current = isMobileViewport ? MOBILE_CANVAS_ZOOM : DESKTOP_CANVAS_ZOOM
|
||||
panRef.current = { x: 0, y: 0 }
|
||||
}, [])
|
||||
}, [isMobileViewport])
|
||||
|
||||
// ── Hovered agent tooltip data ──────────────────────────────
|
||||
const getHoveredAgentInfo = useCallback(() => {
|
||||
@@ -1097,9 +1238,16 @@ export default function PixelOfficePage() {
|
||||
const editor = editorRef.current
|
||||
const selectedItem = editor.selectedFurnitureUid
|
||||
? officeRef.current?.layout.furniture.find(f => f.uid === editor.selectedFurnitureUid) : null
|
||||
const modalOverlayClass = isMobileViewport
|
||||
? "absolute inset-0 z-20 flex items-end justify-center bg-black/50"
|
||||
: "absolute inset-0 z-20 flex items-center justify-center bg-black/40"
|
||||
const modalPanelClass = (desktopWidth = "w-80", maxHeight = "max-h-[80%]") =>
|
||||
isMobileViewport
|
||||
? `w-full ${maxHeight} overflow-y-auto rounded-t-2xl border-x border-t border-[var(--border)] bg-[var(--card)] shadow-2xl p-4 pb-6`
|
||||
: `${desktopWidth} ${maxHeight} overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative overflow-hidden">
|
||||
<div className="relative flex flex-col overflow-hidden h-[calc(100dvh-3.5rem)] md:h-full">
|
||||
{/* Floating photo comment DOM bubbles */}
|
||||
{floatingCommentsRef.current.map(fc => (
|
||||
<div key={fc.key} className="absolute pointer-events-none z-30 whitespace-nowrap"
|
||||
@@ -1123,16 +1271,34 @@ export default function PixelOfficePage() {
|
||||
</div>
|
||||
))}
|
||||
{/* Top bar: agent tags + controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 p-4 border-b border-[var(--border)]">
|
||||
<span className="text-sm font-bold text-[var(--text)] mr-2">{t('pixelOffice.title')}</span>
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
<div className="flex flex-col gap-2 p-3 md:p-4 border-b border-[var(--border)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-bold text-[var(--text)]">{t('pixelOffice.title')}</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={toggleSound}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
soundOn ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
|
||||
: 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{soundOn ? '🔔' : '🔕'} {t('pixelOffice.sound')}
|
||||
</button>
|
||||
<button onClick={toggleEditMode}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
isEditMode ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
|
||||
: 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{isEditMode ? t('pixelOffice.exitEdit') : t('pixelOffice.editMode')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-1 overflow-x-auto pb-1 md:flex-wrap md:overflow-visible">
|
||||
{agents.map(agent => (
|
||||
<div key={agent.agentId} className={`pixel-agent-chip inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
agent.state === 'working' ? 'pixel-agent-chip-working animate-pulse' :
|
||||
agent.state === 'idle' ? 'pixel-agent-chip-idle animate-pulse' :
|
||||
<div key={agent.agentId} className={`shrink-0 pixel-agent-chip inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
agent.state === 'working' ? `pixel-agent-chip-working${isMobileViewport ? '' : ' animate-pulse'}` :
|
||||
agent.state === 'idle' ? `pixel-agent-chip-idle${isMobileViewport ? '' : ' animate-pulse'}` :
|
||||
'pixel-agent-chip-neutral'
|
||||
}`}
|
||||
{...(agent.state === 'working' ? { style: { animationDuration: '1.3s' } } : {})}
|
||||
{...(agent.state === 'working' && !isMobileViewport ? { style: { animationDuration: '1.3s' } } : {})}
|
||||
>
|
||||
<span>{agent.emoji}</span>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
@@ -1146,22 +1312,6 @@ export default function PixelOfficePage() {
|
||||
<div className="text-[var(--text-muted)] text-sm">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={toggleSound}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
soundOn ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
|
||||
: 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{soundOn ? '🔔' : '🔕'} {t('pixelOffice.sound')}
|
||||
</button>
|
||||
<button onClick={toggleEditMode}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
isEditMode ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
|
||||
: 'bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]'
|
||||
}`}>
|
||||
{isEditMode ? t('pixelOffice.exitEdit') : t('pixelOffice.editMode')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
@@ -1169,8 +1319,13 @@ export default function PixelOfficePage() {
|
||||
<canvas ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onContextMenu={handleContextMenu}
|
||||
className="w-full h-full" />
|
||||
className="w-full h-full"
|
||||
style={{ touchAction: 'none' }} />
|
||||
{!officeReady && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-[#1a1a2e]/85 pointer-events-none">
|
||||
<div className="px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-[var(--text-muted)]">
|
||||
@@ -1203,7 +1358,7 @@ export default function PixelOfficePage() {
|
||||
</button>
|
||||
|
||||
{/* Agent hover tooltip */}
|
||||
{hoveredInfo && hoveredInfo.agent && !isEditMode && !selectedAgentId && (
|
||||
{hoveredInfo && hoveredInfo.agent && !isEditMode && !selectedAgentId && !isMobileViewport && (
|
||||
<div className="absolute pointer-events-none z-10 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)]/95 backdrop-blur-sm text-xs shadow-lg"
|
||||
style={{ left: Math.min(mousePosRef.current.x + 12, (containerRef.current?.clientWidth || 300) - 180), top: mousePosRef.current.y + 12 }}>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
@@ -1229,8 +1384,8 @@ export default function PixelOfficePage() {
|
||||
: stats.todayAvgResponseMs > 30000 ? 'text-yellow-400'
|
||||
: 'text-green-400' : 'text-[var(--text-muted)]'
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/40" onClick={() => setSelectedAgentId(null)}>
|
||||
<div className="w-72 rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className={modalOverlayClass} onClick={() => setSelectedAgentId(null)}>
|
||||
<div className={modalPanelClass("w-72", "max-h-[78%]")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{agent.emoji}</span>
|
||||
@@ -1258,8 +1413,8 @@ export default function PixelOfficePage() {
|
||||
|
||||
{/* Model panel (bookshelf click) */}
|
||||
{showModelPanel && !isEditMode && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/40" onClick={() => setShowModelPanel(false)}>
|
||||
<div className="w-80 max-h-[80%] overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className={modalOverlayClass} onClick={() => setShowModelPanel(false)}>
|
||||
<div className={modalPanelClass("w-80")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-[var(--text)]">📚 {t('models.title')}</span>
|
||||
<button onClick={() => setShowModelPanel(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] text-lg leading-none">×</button>
|
||||
@@ -1303,8 +1458,8 @@ export default function PixelOfficePage() {
|
||||
.sort((a, b) => b.tokens - a.tokens)
|
||||
const maxTokens = ranked[0]?.tokens || 1
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/40" onClick={() => setShowTokenRank(false)}>
|
||||
<div className="w-80 rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className={modalOverlayClass} onClick={() => setShowTokenRank(false)}>
|
||||
<div className={modalPanelClass("w-80", "max-h-[78%]")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-[var(--text)]">📊 Token {t('agent.tokenUsage')}</span>
|
||||
<button onClick={() => setShowTokenRank(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] text-lg leading-none">×</button>
|
||||
@@ -1348,8 +1503,8 @@ export default function PixelOfficePage() {
|
||||
const svgW = leftPad + 24 * (cellSize + gap)
|
||||
const svgH = topPad + 7 * (cellSize + gap)
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/40" onClick={() => setShowActivityHeatmap(false)}>
|
||||
<div className="max-h-[85%] overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className={modalOverlayClass} onClick={() => setShowActivityHeatmap(false)}>
|
||||
<div className={modalPanelClass("w-[min(94vw,56rem)]", "max-h-[85%]")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-[var(--text)]">🕐 {t('pixelOffice.heatmap.title')}</span>
|
||||
<button onClick={() => setShowActivityHeatmap(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] text-lg leading-none">×</button>
|
||||
@@ -1370,23 +1525,25 @@ export default function PixelOfficePage() {
|
||||
<span>{agent?.emoji || '🤖'}</span>
|
||||
<span className="text-xs font-semibold text-[var(--text)]">{agent?.name || agentId}</span>
|
||||
</div>
|
||||
<svg width={svgW} height={svgH} className="block">
|
||||
{hourLabels.map(h => (
|
||||
<text key={h} x={leftPad + h * (cellSize + gap) + cellSize / 2} y={topPad - 6} textAnchor="middle" fontSize={9} fill="var(--text-muted)">{h}</text>
|
||||
))}
|
||||
{dayLabels.map((label, d) => (
|
||||
<text key={d} x={leftPad - 4} y={topPad + d * (cellSize + gap) + cellSize / 2 + 3} textAnchor="end" fontSize={9} fill="var(--text-muted)">{label}</text>
|
||||
))}
|
||||
{grid.map((row, d) => row.map((v, h) => {
|
||||
const level = v === 0 ? 0 : Math.min(4, Math.ceil((v / maxVal) * 4))
|
||||
return (
|
||||
<rect key={`${d}-${h}`} x={leftPad + h * (cellSize + gap)} y={topPad + d * (cellSize + gap)}
|
||||
width={cellSize} height={cellSize} rx={2} fill={colors[level]} opacity={0.9}>
|
||||
<title>{`${dayLabels[d]} ${h}:00 — ${v} ${t('pixelOffice.heatmap.messages')}`}</title>
|
||||
</rect>
|
||||
)
|
||||
}))}
|
||||
</svg>
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={svgW} height={svgH} className="block min-w-max">
|
||||
{hourLabels.map(h => (
|
||||
<text key={h} x={leftPad + h * (cellSize + gap) + cellSize / 2} y={topPad - 6} textAnchor="middle" fontSize={9} fill="var(--text-muted)">{h}</text>
|
||||
))}
|
||||
{dayLabels.map((label, d) => (
|
||||
<text key={d} x={leftPad - 4} y={topPad + d * (cellSize + gap) + cellSize / 2 + 3} textAnchor="end" fontSize={9} fill="var(--text-muted)">{label}</text>
|
||||
))}
|
||||
{grid.map((row, d) => row.map((v, h) => {
|
||||
const level = v === 0 ? 0 : Math.min(4, Math.ceil((v / maxVal) * 4))
|
||||
return (
|
||||
<rect key={`${d}-${h}`} x={leftPad + h * (cellSize + gap)} y={topPad + d * (cellSize + gap)}
|
||||
width={cellSize} height={cellSize} rx={2} fill={colors[level]} opacity={0.9}>
|
||||
<title>{`${dayLabels[d]} ${h}:00 — ${v} ${t('pixelOffice.heatmap.messages')}`}</title>
|
||||
</rect>
|
||||
)
|
||||
}))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -1401,8 +1558,8 @@ export default function PixelOfficePage() {
|
||||
{showPhonePanel && !isEditMode && (() => {
|
||||
const info = versionInfo
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/40" onClick={() => setShowPhonePanel(false)}>
|
||||
<div className="w-80 max-h-[80%] overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className={modalOverlayClass} onClick={() => setShowPhonePanel(false)}>
|
||||
<div className={modalPanelClass("w-80")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-[var(--text)]">📱 OpenClaw Latest</span>
|
||||
<button onClick={() => setShowPhonePanel(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] text-lg leading-none">×</button>
|
||||
@@ -1441,8 +1598,8 @@ export default function PixelOfficePage() {
|
||||
})
|
||||
: null
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/40" onClick={() => setShowIdleRank(false)}>
|
||||
<div className="w-80 max-h-[80%] overflow-y-auto rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className={modalOverlayClass} onClick={() => setShowIdleRank(false)}>
|
||||
<div className={modalPanelClass("w-80")} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold text-[var(--text)]">🛋️ {t('pixelOffice.idleRank.title')}</span>
|
||||
<button onClick={() => setShowIdleRank(false)} className="text-[var(--text-muted)] hover:text-[var(--text)] text-lg leading-none">×</button>
|
||||
|
||||
+15
-15
@@ -100,8 +100,8 @@ function AgentPicker() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">💬 {t("nav.sessions")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
@@ -262,25 +262,25 @@ function SessionList({ agentId }: { agentId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">📋 {agentId} {t("sessions.title")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
{sessions.length} {t("sessions.sessionCount")} · {t("sessions.totalToken")}: {(totalTokens / 1000).toFixed(1)}k
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
||||
<button
|
||||
onClick={testAllSessions}
|
||||
disabled={testingAll}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white text-sm font-medium hover:opacity-90 transition disabled:opacity-50"
|
||||
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-[var(--accent)] text-white text-sm font-medium hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
{testingAll ? t("sessions.testingAll") : t("sessions.testAll")}
|
||||
</button>
|
||||
<Link
|
||||
href="/sessions"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition text-center"
|
||||
>
|
||||
{t("sessions.backToAgents")}
|
||||
</Link>
|
||||
@@ -299,20 +299,20 @@ function SessionList({ agentId }: { agentId: string }) {
|
||||
title={t("agent.openChat")}
|
||||
className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)] hover:border-[var(--accent)] transition cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 mb-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${typeInfo.color}`}
|
||||
>
|
||||
{typeInfo.emoji} {typeInfo.label}
|
||||
</span>
|
||||
{s.target && (
|
||||
<code className="text-xs text-[var(--text-muted)] bg-[var(--bg)] px-2 py-0.5 rounded">
|
||||
<code className="text-xs text-[var(--text-muted)] bg-[var(--bg)] px-2 py-0.5 rounded truncate max-w-[52vw] md:max-w-none">
|
||||
{s.target}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 md:self-auto self-start">
|
||||
<button
|
||||
onClick={(e) => testSession(s.key, e)}
|
||||
disabled={testResults[s.key]?.status === "testing"}
|
||||
@@ -337,10 +337,10 @@ function SessionList({ agentId }: { agentId: string }) {
|
||||
<span className="ml-2">{t("sessions.testTime")}: {(testResults[s.key].elapsed! / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
{testResults[s.key].reply && (
|
||||
<span className="ml-2 opacity-80">{t("sessions.testReply")}: {testResults[s.key].reply}</span>
|
||||
<span className="ml-2 opacity-80 break-all">{t("sessions.testReply")}: {testResults[s.key].reply}</span>
|
||||
)}
|
||||
{testResults[s.key].error && (
|
||||
<span className="ml-2 opacity-80">{testResults[s.key].error}</span>
|
||||
<span className="ml-2 opacity-80 break-all">{testResults[s.key].error}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -368,8 +368,8 @@ function SessionList({ agentId }: { agentId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-[var(--text-muted)]">
|
||||
<span className="font-mono text-[10px] opacity-60">{s.key}</span>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-[var(--text-muted)]">
|
||||
<span className="font-mono text-[10px] opacity-60 truncate max-w-[56vw] md:max-w-none">{s.key}</span>
|
||||
<span>{formatTime(s.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+175
-2
@@ -39,6 +39,8 @@ export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useI18n();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [mobileAgentCount, setMobileAgentCount] = useState<number | null>(null);
|
||||
const [experimentOpen, setExperimentOpen] = useState(false);
|
||||
const [bugsEnabled, setBugsEnabled] = useState(false);
|
||||
const [bugsCount, setBugsCount] = useState(5);
|
||||
@@ -187,11 +189,182 @@ export function Sidebar() {
|
||||
|
||||
const logoTransform = `translate(${manualLogoOffset.dx + logoCarry.dx}px, ${manualLogoOffset.dy + logoCarry.dy}px) rotate(${logoCarry.angle + manualLogoAngle}rad)`;
|
||||
const logoCursor = !bugsEnabled ? (isLogoDragging ? "grabbing" : "grab") : "default";
|
||||
const mobileCurrent = NAV_ITEMS.flatMap((g) => g.items).find((item) =>
|
||||
item.href === "/" ? pathname === "/" : pathname.startsWith(item.href)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileMenuOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let aborted = false;
|
||||
const fetchAgentCount = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/config", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (aborted) return;
|
||||
const count = Array.isArray(data?.agents) ? data.agents.length : 0;
|
||||
setMobileAgentCount(count);
|
||||
} catch {}
|
||||
};
|
||||
if (pathname === "/") {
|
||||
void fetchAgentCount();
|
||||
const timer = setInterval(fetchAgentCount, 30000);
|
||||
return () => {
|
||||
aborted = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden">
|
||||
<header className="fixed inset-x-0 top-0 z-50 h-14 border-b border-[var(--border)] bg-[var(--card)]/95 backdrop-blur">
|
||||
<div className="h-full px-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="w-9 h-9 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] text-base"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-2xl leading-none">🦞</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-bold tracking-wide truncate">OPENCLAW</div>
|
||||
<div className="text-[10px] text-[var(--text-muted)] truncate">
|
||||
{pathname === "/" && mobileAgentCount !== null
|
||||
? `${mobileAgentCount} ${t("home.agentCount")}`
|
||||
: mobileCurrent ? t(mobileCurrent.labelKey) : "BOT DASHBOARD"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<div className="fixed inset-0 z-[55]">
|
||||
<button
|
||||
className="absolute inset-0 bg-black/45"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Close menu overlay"
|
||||
/>
|
||||
<aside className="absolute top-0 left-0 bottom-0 w-[276px] max-w-[86vw] border-r border-[var(--border)] bg-[var(--card)] shadow-2xl flex flex-col">
|
||||
<div className="h-14 px-3 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<div className="font-semibold text-sm">Navigation</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="w-8 h-8 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)]"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-4">
|
||||
{NAV_ITEMS.map((group) => (
|
||||
<div key={group.group}>
|
||||
<div className="px-1 mb-1 text-[10px] font-semibold text-[var(--text-muted)] uppercase tracking-wider">
|
||||
{t(group.group)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-[var(--accent)]/15 text-[var(--accent)] font-medium"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text)] hover:bg-[var(--bg)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)]/65 p-2">
|
||||
<button
|
||||
onClick={() => setExperimentOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between rounded-lg px-3 py-2 transition-colors ${
|
||||
experimentOpen
|
||||
? "bg-[var(--accent)]/12 text-[var(--accent)] border border-[var(--accent)]/35"
|
||||
: "bg-[var(--bg)] text-[var(--text)] border border-[var(--border)] hover:bg-[var(--accent)]/8"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-sm">🧪</span>
|
||||
<span className="text-sm font-semibold tracking-wide">{t("nav.experiments")}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center justify-center text-base leading-none transition-transform ${
|
||||
experimentOpen ? "text-[var(--accent)] rotate-180" : "text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
⌄
|
||||
</span>
|
||||
</button>
|
||||
{experimentOpen && (
|
||||
<div className="mt-2 space-y-2 rounded-lg border border-[var(--border)] bg-[var(--card)] p-2">
|
||||
<button
|
||||
onClick={toggleBugs}
|
||||
className={`w-full px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
bugsEnabled
|
||||
? "bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]"
|
||||
: "bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{bugsEnabled ? `🐛 ${t("nav.bugsOn")}` : `🐛 ${t("nav.bugsOff")}`}
|
||||
</button>
|
||||
<label className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs rounded-lg border bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)]">
|
||||
<span>{t("nav.bugsCount")} {bugsCount}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={BUGS_MAX}
|
||||
step={1}
|
||||
value={bugsCount}
|
||||
onChange={(e) => onBugCountChange(Number(e.target.value))}
|
||||
className="w-24 accent-[var(--accent)]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className="sidebar"
|
||||
className="sidebar hidden md:flex"
|
||||
style={{ width: collapsed ? 64 : 224 }}
|
||||
>
|
||||
{/* Header: Logo + Toggle */}
|
||||
@@ -371,7 +544,7 @@ export function Sidebar() {
|
||||
</aside>
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ width: collapsed ? 64 : 224, flexShrink: 0, transition: "width 0.2s" }} />
|
||||
<div className="hidden md:block" style={{ width: collapsed ? 64 : 224, flexShrink: 0, transition: "width 0.2s" }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-6
@@ -79,15 +79,15 @@ export default function SkillsPage() {
|
||||
const customCount = skills.filter((s) => s.source === "custom").length;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("skills.title")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
共 {skills.length} {t("skills.count")}({t("skills.builtin")} {builtinCount} / {t("skills.extension")} {extCount} / {t("skills.custom")} {customCount})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm font-medium hover:border-[var(--accent)] transition"
|
||||
@@ -98,8 +98,8 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center">
|
||||
<div className="flex flex-wrap rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
{(["all", "builtin", "extension", "custom"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
@@ -119,7 +119,7 @@ export default function SkillsPage() {
|
||||
placeholder={t("skills.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-64"
|
||||
className="px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm outline-none focus:border-[var(--accent)] transition w-full md:w-64"
|
||||
/>
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{t("skills.showing")} {filtered.length} {t("skills.unit")}
|
||||
|
||||
+14
-12
@@ -256,8 +256,8 @@ function StatsAgentPicker() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">📊 {t("stats.title")}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
@@ -365,23 +365,24 @@ function StatsDetail({ agentId }: { agentId: string }) {
|
||||
const totalMessages = currentData.reduce((s, d) => s + d.messageCount, 0);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen p-8 max-w-6xl mx-auto">
|
||||
<main className="min-h-screen p-4 md:p-8 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex flex-col gap-3 mb-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{`📊 ${agentId} ${t("stats.title")}`}</h1>
|
||||
<p className="text-[var(--text-muted)] text-sm mt-1">
|
||||
{t("stats.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
||||
{/* Time range selector */}
|
||||
<div className="flex rounded-lg border border-[var(--border)] overflow-hidden">
|
||||
<div className="max-w-full overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||
<div className="flex min-w-max">
|
||||
{(["daily", "weekly", "monthly"] as TimeRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-4 py-2 text-sm transition ${
|
||||
className={`px-3 py-2 text-xs md:text-sm transition ${
|
||||
range === r
|
||||
? "bg-[var(--accent)] text-[var(--bg)] font-medium"
|
||||
: "bg-[var(--card)] text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
@@ -390,16 +391,17 @@ function StatsDetail({ agentId }: { agentId: string }) {
|
||||
{getRangeLabel(r)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/sessions?agent=${agentId}`}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition text-center"
|
||||
>
|
||||
{t("stats.sessionList")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/stats`}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition"
|
||||
className="w-full sm:w-auto px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm hover:border-[var(--accent)] transition text-center"
|
||||
>
|
||||
{t("stats.backToAgents")}
|
||||
</Link>
|
||||
@@ -407,7 +409,7 @@ function StatsDetail({ agentId }: { agentId: string }) {
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<div className="text-xs text-[var(--text-muted)] mb-1">{t("stats.totalInputToken")}</div>
|
||||
<div className="text-xl font-bold text-blue-400">{formatTokens(totalInput)}</div>
|
||||
@@ -428,9 +430,9 @@ function StatsDetail({ agentId }: { agentId: string }) {
|
||||
|
||||
{/* Token chart */}
|
||||
<div className="p-5 rounded-xl border border-[var(--border)] bg-[var(--card)] mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col gap-2 mb-4 md:flex-row md:items-center md:justify-between">
|
||||
<h2 className="text-sm font-semibold text-[var(--text)]">{t("stats.tokenConsumption")}</h2>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-sm bg-blue-500 inline-block" /> Input</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-sm bg-emerald-500 inline-block" /> Output</span>
|
||||
</div>
|
||||
|
||||
+16
-16
@@ -65,8 +65,8 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"home.defaultModel": "默认模型",
|
||||
"home.viewModels": "查看模型列表 →",
|
||||
"home.skillMgmt": "🧩 技能管理",
|
||||
"home.testAll": "🧪 测试绑定的模型",
|
||||
"home.testingAll": "⏳ 测试中...",
|
||||
"home.testAll": "测试绑定的模型",
|
||||
"home.testingAll": "测试中...",
|
||||
"home.testOk": "正常",
|
||||
"home.testFail": "异常",
|
||||
"home.updatedAt": "更新于",
|
||||
@@ -83,12 +83,12 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"home.discordChannel": "Discord 频道",
|
||||
"home.bots": "个机器人",
|
||||
"home.noResponseData": "暂无响应时间数据",
|
||||
"home.testPlatforms": "🧪 测试平台连通",
|
||||
"home.testingPlatforms": "⏳ 测试平台中...",
|
||||
"home.testSessions": "🔌 测试 Agent",
|
||||
"home.testingSessions": "⏳ 测试中...",
|
||||
"home.testDmSessions": "💬 测试 DM Session",
|
||||
"home.testingDmSessions": "⏳ 测试中...",
|
||||
"home.testPlatforms": "测试平台连通",
|
||||
"home.testingPlatforms": "测试平台中...",
|
||||
"home.testSessions": "测试 Agent",
|
||||
"home.testingSessions": "测试中...",
|
||||
"home.testDmSessions": "测试 DM Session",
|
||||
"home.testingDmSessions": "测试中...",
|
||||
|
||||
// agent card
|
||||
"agent.model": "模型",
|
||||
@@ -303,8 +303,8 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"home.defaultModel": "Default model",
|
||||
"home.viewModels": "View Models →",
|
||||
"home.skillMgmt": "🧩 Skills",
|
||||
"home.testAll": "🧪 Test Bound Models",
|
||||
"home.testingAll": "⏳ Testing...",
|
||||
"home.testAll": "Test Bound Models",
|
||||
"home.testingAll": "Testing...",
|
||||
"home.testOk": "OK",
|
||||
"home.testFail": "Failed",
|
||||
"home.updatedAt": "Updated at",
|
||||
@@ -321,12 +321,12 @@ const translations: Record<Locale, Record<string, string>> = {
|
||||
"home.discordChannel": "Discord Channel",
|
||||
"home.bots": "bots",
|
||||
"home.noResponseData": "No response time data",
|
||||
"home.testPlatforms": "🧪 Test Platforms",
|
||||
"home.testingPlatforms": "⏳ Testing Platforms...",
|
||||
"home.testSessions": "🔌 Test Agents",
|
||||
"home.testingSessions": "⏳ Testing...",
|
||||
"home.testDmSessions": "💬 Test DM Sessions",
|
||||
"home.testingDmSessions": "⏳ Testing...",
|
||||
"home.testPlatforms": "Test Platforms",
|
||||
"home.testingPlatforms": "Testing Platforms...",
|
||||
"home.testSessions": "Test Agents",
|
||||
"home.testingSessions": "Testing...",
|
||||
"home.testDmSessions": "Test DM Sessions",
|
||||
"home.testingDmSessions": "Testing...",
|
||||
|
||||
// agent card
|
||||
"agent.model": "Model",
|
||||
|
||||
Reference in New Issue
Block a user