Add Pixel Office trading management UI
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
d65dca041d
commit
e5e78373ee
5 files changed
+389
-9
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useSearchParams } from "react-router";
|
||||
import { Activity, BarChart3, Bot, CalendarClock, CandlestickChart, Check, ChevronDown, FileText, Languages, Moon, Sun, Plus, Trash2, Pencil, MessageSquare, ChevronsLeft, ChevronsRight, Settings, Layers, Loader2, WalletCards } from "lucide-react";
|
||||
import { Activity, BarChart3, Bot, CalendarClock, CandlestickChart, Check, ChevronDown, FileText, Languages, Moon, Sun, Plus, Trash2, Pencil, MessageSquare, ChevronsLeft, ChevronsRight, Settings, Layers, Loader2, WalletCards, Building2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import { api, type SessionItem } from "@/lib/api";
|
||||
@@ -21,6 +21,7 @@ export function Layout() {
|
||||
// matches both "/" and legacy "/agent" deep links.
|
||||
const NAV = [
|
||||
{ to: "/", icon: Bot, label: t('layout.agent') },
|
||||
{ to: "/pixel-office", icon: Building2, label: "Pixel Office" },
|
||||
{ to: "/runtime", icon: Activity, label: t('layout.runtime') },
|
||||
{ to: "/scheduled", icon: CalendarClock, label: t('layout.scheduled') },
|
||||
{ to: "/reports", icon: FileText, label: t('layout.reports') },
|
||||
|
||||
@@ -109,6 +109,84 @@
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* Lightweight office sprites used by the native Pixel Office management view. */
|
||||
.pixel-office-floor {
|
||||
background-color: hsl(var(--card));
|
||||
background-image:
|
||||
linear-gradient(hsl(var(--border) / 0.22) 1px, transparent 1px),
|
||||
linear-gradient(90deg, hsl(var(--border) / 0.22) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
.pixel-desk {
|
||||
background: hsl(var(--background) / 0.82);
|
||||
box-shadow: inset 0 -10px 0 hsl(var(--muted) / 0.32);
|
||||
}
|
||||
.pixel-character {
|
||||
position: relative;
|
||||
width: 66px;
|
||||
height: 70px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.pixel-character__shadow {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 3px;
|
||||
width: 48px;
|
||||
height: 8px;
|
||||
background: hsl(var(--foreground) / 0.14);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.pixel-character__body {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 6px;
|
||||
width: 34px;
|
||||
height: 55px;
|
||||
filter: drop-shadow(2px 3px 0 hsl(var(--foreground) / 0.2));
|
||||
}
|
||||
.pixel-character__head {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #f1b48b;
|
||||
box-shadow: 0 -5px 0 #3b2a24, 5px -5px 0 #3b2a24, 10px -5px 0 #3b2a24, 0 5px 0 #f1b48b, 24px 5px 0 #f1b48b;
|
||||
}
|
||||
.pixel-character__torso {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 29px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: var(--pixel-color);
|
||||
}
|
||||
.pixel-character__arm,
|
||||
.pixel-character__leg {
|
||||
position: absolute;
|
||||
width: 7px;
|
||||
background: var(--pixel-color);
|
||||
}
|
||||
.pixel-character__arm { top: 30px; height: 18px; }
|
||||
.pixel-character__arm--left { left: 0; }
|
||||
.pixel-character__arm--right { right: 0; }
|
||||
.pixel-character__leg { top: 49px; height: 12px; background: #334155; }
|
||||
.pixel-character__leg--left { left: 7px; }
|
||||
.pixel-character__leg--right { right: 7px; }
|
||||
.pixel-character__typing {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 2px;
|
||||
color: var(--pixel-color);
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
animation: pixel-typing 900ms steps(2, end) infinite;
|
||||
}
|
||||
@keyframes pixel-typing {
|
||||
0%, 100% { opacity: 0.25; transform: translateY(2px); }
|
||||
50% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Message/hero entrance — one subtle rise per mount; removed under reduced motion */
|
||||
@keyframes msg-enter {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
||||
import { Check, Crown, Plus, Shield, Target, UserRound } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentStatus = "idle" | "busy" | "waiting";
|
||||
type AgentRank = "Operator" | "Senior" | "Lead";
|
||||
|
||||
type OfficeAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
room: string;
|
||||
status: AgentStatus;
|
||||
rank: AgentRank;
|
||||
speciality: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type OfficeTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
priority: "Low" | "Normal" | "High";
|
||||
assignee: string;
|
||||
status: "Queued" | "In progress" | "Completed";
|
||||
};
|
||||
|
||||
const INITIAL_AGENTS: OfficeAgent[] = [
|
||||
{ id: "research-analyst", name: "Research Analyst", title: "Market Research", room: "Research desk", status: "idle", rank: "Lead", speciality: "Macro, equity, crypto and news", color: "#f59e0b" },
|
||||
{ id: "risk-guard", name: "Risk Guard", title: "Risk Management", room: "Risk control", status: "idle", rank: "Senior", speciality: "Drawdown, exposure and execution risk", color: "#38bdf8" },
|
||||
{ id: "execution-agent", name: "Execution Agent", title: "Trade Operations", room: "Execution desk", status: "idle", rank: "Senior", speciality: "Broker routing and paper orders", color: "#a78bfa" },
|
||||
];
|
||||
|
||||
const INITIAL_TASKS: OfficeTask[] = [
|
||||
{ id: "office-welcome", title: "Set up today's trading brief", detail: "Review overnight market context and list the top risks.", priority: "Normal", assignee: "research-analyst", status: "Queued" },
|
||||
];
|
||||
|
||||
const statusLabel: Record<AgentStatus, string> = {
|
||||
idle: "Available",
|
||||
busy: "Working",
|
||||
waiting: "Waiting",
|
||||
};
|
||||
|
||||
function PixelCharacter({ color, status }: Pick<OfficeAgent, "color" | "status">) {
|
||||
return (
|
||||
<div className="pixel-character" style={{ "--pixel-color": color } as CSSProperties} aria-label={`${statusLabel[status]} pixel agent`}>
|
||||
<div className="pixel-character__shadow" />
|
||||
<div className="pixel-character__body">
|
||||
<span className="pixel-character__head" />
|
||||
<span className="pixel-character__torso" />
|
||||
<span className="pixel-character__arm pixel-character__arm--left" />
|
||||
<span className="pixel-character__arm pixel-character__arm--right" />
|
||||
<span className="pixel-character__leg pixel-character__leg--left" />
|
||||
<span className="pixel-character__leg pixel-character__leg--right" />
|
||||
</div>
|
||||
{status === "busy" && <span className="pixel-character__typing" aria-hidden="true">...</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PixelOffice() {
|
||||
const [agents, setAgents] = useState<OfficeAgent[]>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("vibe-pixel-office-agents");
|
||||
return stored ? JSON.parse(stored) as OfficeAgent[] : INITIAL_AGENTS;
|
||||
} catch {
|
||||
return INITIAL_AGENTS;
|
||||
}
|
||||
});
|
||||
const [tasks, setTasks] = useState<OfficeTask[]>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("vibe-pixel-office-tasks");
|
||||
return stored ? JSON.parse(stored) as OfficeTask[] : INITIAL_TASKS;
|
||||
} catch {
|
||||
return INITIAL_TASKS;
|
||||
}
|
||||
});
|
||||
const [selectedAgent, setSelectedAgent] = useState(INITIAL_AGENTS[0].id);
|
||||
const [taskTitle, setTaskTitle] = useState("");
|
||||
const [taskDetail, setTaskDetail] = useState("");
|
||||
const [taskPriority, setTaskPriority] = useState<OfficeTask["priority"]>("Normal");
|
||||
|
||||
const workload = useMemo(() => agents.map((agent) => ({
|
||||
...agent,
|
||||
tasks: tasks.filter((task) => task.assignee === agent.id && task.status !== "Completed"),
|
||||
})), [agents, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("vibe-pixel-office-agents", JSON.stringify(agents));
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("vibe-pixel-office-tasks", JSON.stringify(tasks));
|
||||
}, [tasks]);
|
||||
|
||||
const assignTask = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!taskTitle.trim()) return;
|
||||
const id = `task-${Date.now()}`;
|
||||
setTasks((current) => [...current, {
|
||||
id,
|
||||
title: taskTitle.trim(),
|
||||
detail: taskDetail.trim() || "No extra instructions.",
|
||||
priority: taskPriority,
|
||||
assignee: selectedAgent,
|
||||
status: "Queued",
|
||||
}]);
|
||||
setAgents((current) => current.map((agent) => agent.id === selectedAgent ? { ...agent, status: "busy" } : agent));
|
||||
setTaskTitle("");
|
||||
setTaskDetail("");
|
||||
};
|
||||
|
||||
const completeTask = (taskId: string) => {
|
||||
const task = tasks.find((item) => item.id === taskId);
|
||||
if (!task) return;
|
||||
setTasks((current) => current.map((item) => item.id === taskId ? { ...item, status: "Completed" } : item));
|
||||
const hasOtherWork = tasks.some((item) => item.id !== taskId && item.assignee === task.assignee && item.status !== "Completed");
|
||||
if (!hasOtherWork) {
|
||||
setAgents((current) => current.map((agent) => agent.id === task.assignee ? { ...agent, status: "idle" } : agent));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-y-auto bg-background px-4 py-5 sm:px-6">
|
||||
<div className="mx-auto max-w-7xl space-y-5">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary">Pixel Agents</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight">Trading Office</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Görevleri agent'lara dağıt, rütbelerini yönet ve trading operasyonunu tek ofisten izle.
|
||||
Agent isimleri sabittir; yalnızca rütbe ve görevleri yönetilebilir.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Office online
|
||||
<span className="mx-1 text-border">|</span>
|
||||
{agents.filter((agent) => agent.status === "busy").length} working
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="pixel-office-floor rounded-xl border border-border/80 p-4 shadow-sm sm:p-6" aria-label="Pixel Agents office">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Office floor</h2>
|
||||
<p className="text-xs text-muted-foreground">Pixel karakterler görev durumuna göre hareket eder.</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-background/70 px-3 py-1 text-xs text-muted-foreground">Trading HQ</span>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{workload.map((agent) => (
|
||||
<button
|
||||
type="button"
|
||||
key={agent.id}
|
||||
onClick={() => setSelectedAgent(agent.id)}
|
||||
className={cn("pixel-desk rounded-lg border p-4 text-left transition-all", selectedAgent === agent.id ? "border-primary ring-2 ring-primary/20" : "border-border/70")}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<PixelCharacter color={agent.color} status={agent.status} />
|
||||
<span className={cn("rounded-full px-2 py-1 text-[10px] font-medium", agent.status === "busy" ? "bg-amber-500/15 text-amber-600" : "bg-emerald-500/15 text-emerald-600")}>
|
||||
{statusLabel[agent.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-2 font-semibold">{agent.name}<span className="text-xs font-normal text-muted-foreground">· {agent.rank}</span></div>
|
||||
<p className="text-xs text-muted-foreground">{agent.title} · {agent.room}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{agent.speciality}</p>
|
||||
</div>
|
||||
{agent.tasks.length > 0 && <p className="mt-3 text-xs text-primary">{agent.tasks.length} active task{agent.tasks.length > 1 ? "s" : ""}</p>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Task board</h2>
|
||||
<p className="text-xs text-muted-foreground">Atanan işler burada izlenir.</p>
|
||||
</div>
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => {
|
||||
const owner = agents.find((agent) => agent.id === task.assignee);
|
||||
return (
|
||||
<div key={task.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{task.title}</p>
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">{task.priority}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{task.detail}</p>
|
||||
<p className="mt-2 text-xs text-primary">{owner?.name} · {task.status}</p>
|
||||
</div>
|
||||
{task.status !== "Completed" ? (
|
||||
<button type="button" onClick={() => completeTask(task.id)} className="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-muted">
|
||||
<Check className="h-3.5 w-3.5" /> Complete
|
||||
</button>
|
||||
) : <span className="inline-flex items-center gap-1 text-xs text-emerald-600"><Check className="h-3.5 w-3.5" /> Done</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tasks.length === 0 && <p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">No tasks yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Assign a mission</h2>
|
||||
<p className="text-xs text-muted-foreground">İsimler sabit, görev ve rütbe yönetilebilir.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form className="space-y-3" onSubmit={assignTask}>
|
||||
<label className="block text-xs font-medium">Agent
|
||||
<select value={selectedAgent} onChange={(event) => setSelectedAgent(event.target.value)} className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs font-medium">Mission
|
||||
<input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="Örn. BTC risk analizi" className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label className="block text-xs font-medium">Instructions
|
||||
<textarea value={taskDetail} onChange={(event) => setTaskDetail(event.target.value)} placeholder="Agent'ın teslim etmesini istediğin çıktı..." rows={3} className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label className="block text-xs font-medium">Priority
|
||||
<select value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as OfficeTask["priority"])} className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<option>Low</option><option>Normal</option><option>High</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="w-full rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90">Assign mission</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2"><Crown className="h-4 w-4 text-primary" /><h2 className="font-semibold">Rank & permissions</h2></div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{agents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-3 rounded-lg border border-border/70 p-3">
|
||||
<UserRound className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{agent.name}</p><p className="text-xs text-muted-foreground">{agent.title}</p></div>
|
||||
<select aria-label={`${agent.name} rank`} value={agent.rank} onChange={(event) => setAgents((current) => current.map((item) => item.id === agent.id ? { ...item, rank: event.target.value as AgentRank } : item))} className="rounded-md border border-border bg-background px-2 py-1 text-xs">
|
||||
<option>Operator</option><option>Senior</option><option>Lead</option>
|
||||
</select>
|
||||
<Shield className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ const AlphaZoo = lazy(() =>
|
||||
const OptionsLab = lazy(() =>
|
||||
import("@/pages/OptionsLab").then((m) => ({ default: m.OptionsLab })),
|
||||
);
|
||||
const PixelOffice = lazy(() =>
|
||||
import("@/pages/PixelOffice").then((m) => ({ default: m.PixelOffice })),
|
||||
);
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -58,6 +61,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "/", element: wrap(Agent) },
|
||||
{ path: "/about", element: wrap(Home) },
|
||||
{ path: "/agent", element: wrap(Agent) },
|
||||
{ path: "/pixel-office", element: wrap(PixelOffice) },
|
||||
{ path: "/runtime", element: wrap(Runtime) },
|
||||
{ path: "/scheduled", element: wrap(Scheduled) },
|
||||
{ path: "/reports", element: wrap(Reports) },
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
"id": "research-analyst",
|
||||
"name": "Research Analyst",
|
||||
"role": "research",
|
||||
"status": "idle",
|
||||
"load": 0,
|
||||
"specialities": ["macro", "equity", "crypto", "news"],
|
||||
"status": "busy",
|
||||
"load": 1,
|
||||
"specialities": [
|
||||
"macro",
|
||||
"crypto",
|
||||
"risk"
|
||||
],
|
||||
"office": "trading-floor"
|
||||
},
|
||||
{
|
||||
@@ -17,8 +21,12 @@
|
||||
"role": "risk",
|
||||
"status": "idle",
|
||||
"load": 0,
|
||||
"specialities": ["risk", "drawdown", "execution"],
|
||||
"office": "trade-ops"
|
||||
"specialities": [
|
||||
"risk",
|
||||
"drawdown",
|
||||
"execution"
|
||||
],
|
||||
"office": "trading-floor"
|
||||
},
|
||||
{
|
||||
"id": "execution-agent",
|
||||
@@ -26,9 +34,40 @@
|
||||
"role": "execution",
|
||||
"status": "idle",
|
||||
"load": 0,
|
||||
"specialities": ["broker", "orders", "routing"],
|
||||
"office": "live-trading"
|
||||
"specialities": [
|
||||
"broker",
|
||||
"orders",
|
||||
"routing"
|
||||
],
|
||||
"office": "trading-floor"
|
||||
}
|
||||
],
|
||||
"tasks": []
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task-001",
|
||||
"title": "Research BTC",
|
||||
"description": "BTC thesis and risk review",
|
||||
"workstream": "research",
|
||||
"priority": "high",
|
||||
"owner": "ops",
|
||||
"status": "assigned",
|
||||
"assigned_to": "research-analyst",
|
||||
"created_at": "2026-09-22T11:56:19Z",
|
||||
"updated_at": "2026-09-22T11:59:00Z",
|
||||
"result": ""
|
||||
},
|
||||
{
|
||||
"id": "task-001",
|
||||
"title": "Research BTC",
|
||||
"description": "BTC thesis and risk review",
|
||||
"workstream": "research",
|
||||
"priority": "high",
|
||||
"owner": "ops",
|
||||
"status": "queued",
|
||||
"assigned_to": null,
|
||||
"created_at": "2026-09-22T11:58:51Z",
|
||||
"updated_at": "2026-09-22T11:58:51Z",
|
||||
"result": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user