feat: refine mobile adaptation and pixel office viewport behavior

This commit is contained in:
xmanrui
2026-03-03 03:00:37 +08:00
parent 5cdf5e4c1d
commit 216b566bc2
12 changed files with 624 additions and 197 deletions
+4 -4
View File
@@ -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
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",