feat: retro Claude ekip konsolunu kur
This commit is contained in:
68
web/src/App.jsx
Normal file
68
web/src/App.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react";
|
||||
import ShellFrame from "./components/ShellFrame.jsx";
|
||||
import StatusStrip from "./components/StatusStrip.jsx";
|
||||
import SessionToolbar from "./components/SessionToolbar.jsx";
|
||||
import ChatStream from "./components/ChatStream.jsx";
|
||||
import PromptComposer from "./components/PromptComposer.jsx";
|
||||
import TeamBoard from "./components/TeamBoard.jsx";
|
||||
import { useSocket } from "./hooks/useSocket.js";
|
||||
import { useSession } from "./hooks/useSession.js";
|
||||
|
||||
export default function App() {
|
||||
const { socket, connected } = useSocket();
|
||||
const { session, chat, error, startSession, stopSession, activateTeam, sendPrompt, clearError } = useSession(socket);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function runAction(action) {
|
||||
setBusy(true);
|
||||
clearError();
|
||||
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<div className="app-shell__header">
|
||||
<div>
|
||||
<p className="app-shell__eyebrow">1996 COMMAND CENTER</p>
|
||||
<h1>Retro Claude Team Console</h1>
|
||||
</div>
|
||||
<div className="app-shell__meta">
|
||||
<span>LIVE STREAM</span>
|
||||
<span>TEAM COMMS</span>
|
||||
<span>PIXEL MODE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShellFrame>
|
||||
<StatusStrip connected={connected} session={session} />
|
||||
<SessionToolbar
|
||||
session={session}
|
||||
busy={busy}
|
||||
onStart={() => runAction(startSession)}
|
||||
onActivate={() => runAction(activateTeam)}
|
||||
onStop={() => runAction(stopSession)}
|
||||
/>
|
||||
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
|
||||
<div className="console-grid">
|
||||
<div className="console-grid__main">
|
||||
<ChatStream chat={chat} session={session} />
|
||||
<PromptComposer
|
||||
disabled={busy || session.status !== "running"}
|
||||
onSubmit={(prompt) => runAction(() => sendPrompt(prompt))}
|
||||
/>
|
||||
</div>
|
||||
<div className="console-grid__side">
|
||||
<TeamBoard chat={chat} />
|
||||
</div>
|
||||
</div>
|
||||
</ShellFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
33
web/src/components/ChatStream.jsx
Normal file
33
web/src/components/ChatStream.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import PanelFrame from "./PanelFrame.jsx";
|
||||
|
||||
export default function ChatStream({ chat, session }) {
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollerRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}, [chat]);
|
||||
|
||||
const isEmpty = !chat.trim();
|
||||
|
||||
return (
|
||||
<PanelFrame title="Claude Live Feed" eyebrow="PRIMARY STREAM" className="chat-panel">
|
||||
<div className="chat-stream" ref={scrollerRef}>
|
||||
{isEmpty ? (
|
||||
<div className="empty-state">
|
||||
<span>NO ACTIVE SESSION</span>
|
||||
<span>PRESS START TO BOOT CLAUDE CONSOLE</span>
|
||||
{session.runtime?.anthropicBaseUrl ? <span>ROUTE: {session.runtime.anthropicBaseUrl}</span> : null}
|
||||
</div>
|
||||
) : (
|
||||
<pre>{chat}</pre>
|
||||
)}
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
13
web/src/components/PanelFrame.jsx
Normal file
13
web/src/components/PanelFrame.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function PanelFrame({ title, eyebrow, children, className = "" }) {
|
||||
return (
|
||||
<section className={`panel-frame ${className}`}>
|
||||
<div className="panel-frame__header">
|
||||
<div>
|
||||
<p className="panel-frame__eyebrow">{eyebrow}</p>
|
||||
<h2 className="panel-frame__title">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel-frame__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
7
web/src/components/PixelButton.jsx
Normal file
7
web/src/components/PixelButton.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function PixelButton({ tone = "green", disabled, children, ...props }) {
|
||||
return (
|
||||
<button className={`pixel-button pixel-button--${tone}`} disabled={disabled} {...props}>
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
43
web/src/components/PromptComposer.jsx
Normal file
43
web/src/components/PromptComposer.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
|
||||
export default function PromptComposer({ disabled, onSubmit }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
async function handleSubmit() {
|
||||
const prompt = value.trim();
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(prompt);
|
||||
setValue("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prompt-composer">
|
||||
<label className="prompt-composer__label" htmlFor="prompt-box">
|
||||
COMMAND INPUT
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt-box"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Write a prompt and hit Enter..."
|
||||
/>
|
||||
<div className="prompt-composer__actions">
|
||||
<span>Enter = send / Shift+Enter = newline</span>
|
||||
<PixelButton tone="amber" disabled={disabled || !value.trim()} onClick={handleSubmit}>
|
||||
Send Prompt
|
||||
</PixelButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
web/src/components/SessionToolbar.jsx
Normal file
20
web/src/components/SessionToolbar.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
|
||||
export default function SessionToolbar({ session, busy, onStart, onActivate, onStop }) {
|
||||
const isRunning = session.status === "running";
|
||||
const isStarting = session.status === "starting";
|
||||
|
||||
return (
|
||||
<div className="session-toolbar">
|
||||
<PixelButton tone="green" disabled={busy || isRunning || isStarting} onClick={onStart}>
|
||||
Start Session
|
||||
</PixelButton>
|
||||
<PixelButton tone="cyan" disabled={busy || !isRunning} onClick={onActivate}>
|
||||
Activate Team
|
||||
</PixelButton>
|
||||
<PixelButton tone="red" disabled={busy || (!isRunning && session.status !== "starting")} onClick={onStop}>
|
||||
Stop Session
|
||||
</PixelButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
web/src/components/ShellFrame.jsx
Normal file
12
web/src/components/ShellFrame.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function ShellFrame({ children }) {
|
||||
return (
|
||||
<div className="shell-frame">
|
||||
<div className="shell-frame__bezel" />
|
||||
<div className="shell-frame__screen">
|
||||
<div className="shell-frame__scanlines" />
|
||||
<div className="shell-frame__noise" />
|
||||
<div className="shell-frame__content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
web/src/components/StatusStrip.jsx
Normal file
43
web/src/components/StatusStrip.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
function labelForStatus(status) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "RUNNING";
|
||||
case "starting":
|
||||
return "STARTING";
|
||||
case "stopped":
|
||||
return "STOPPED";
|
||||
case "error":
|
||||
return "ERROR";
|
||||
default:
|
||||
return "IDLE";
|
||||
}
|
||||
}
|
||||
|
||||
export default function StatusStrip({ connected, session }) {
|
||||
return (
|
||||
<div className="status-strip">
|
||||
<div className="status-strip__cell">
|
||||
<span className="status-strip__label">LINK</span>
|
||||
<strong className={connected ? "is-green" : "is-red"}>{connected ? "ONLINE" : "OFFLINE"}</strong>
|
||||
</div>
|
||||
<div className="status-strip__cell">
|
||||
<span className="status-strip__label">SESSION</span>
|
||||
<strong>{labelForStatus(session.status)}</strong>
|
||||
</div>
|
||||
<div className="status-strip__cell">
|
||||
<span className="status-strip__label">TEAM</span>
|
||||
<strong className={session.teamActivated ? "is-cyan" : "is-amber"}>
|
||||
{session.teamActivated ? "ACTIVE" : "STANDBY"}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="status-strip__cell">
|
||||
<span className="status-strip__label">MODEL</span>
|
||||
<strong>{session.runtime?.anthropicModel || "N/A"}</strong>
|
||||
</div>
|
||||
<div className="status-strip__cell">
|
||||
<span className="status-strip__label">KEY</span>
|
||||
<strong>{String(session.runtime?.activeKey || "pro").toUpperCase()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
web/src/components/TeamBoard.jsx
Normal file
48
web/src/components/TeamBoard.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import PanelFrame from "./PanelFrame.jsx";
|
||||
import { parseTeamFeed } from "../lib/teamFeed.js";
|
||||
|
||||
function TeamCard({ member }) {
|
||||
return (
|
||||
<article className="team-card">
|
||||
<header className="team-card__header">
|
||||
<div className="team-card__identity">
|
||||
<span className="team-card__icon">{member.icon}</span>
|
||||
<div>
|
||||
<h3>{member.name}</h3>
|
||||
<p>{member.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="team-card__count">{member.messages.length}</span>
|
||||
</header>
|
||||
|
||||
<div className="team-card__body">
|
||||
{member.messages.length === 0 ? (
|
||||
<div className="empty-state empty-state--small">
|
||||
<span>NO SIGNAL YET</span>
|
||||
</div>
|
||||
) : (
|
||||
member.messages.slice(-4).map((message) => (
|
||||
<article key={message.id} className="team-message">
|
||||
<span className="team-message__speaker">{message.speaker}:</span>
|
||||
<pre>{message.text}</pre>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamBoard({ chat }) {
|
||||
const members = parseTeamFeed(chat);
|
||||
|
||||
return (
|
||||
<PanelFrame title="Team Comms Board" eyebrow="ROLE SIGNALS" className="team-board-panel">
|
||||
<div className="team-board">
|
||||
{members.map((member) => (
|
||||
<TeamCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
46
web/src/components/WatchLogPanel.jsx
Normal file
46
web/src/components/WatchLogPanel.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import PanelFrame from "./PanelFrame.jsx";
|
||||
import PixelButton from "./PixelButton.jsx";
|
||||
import { formatLogTime, logTypeLabel } from "../lib/formatLogLine.js";
|
||||
|
||||
export default function WatchLogPanel({ logs, onClear }) {
|
||||
const scrollerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollerRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}, [logs]);
|
||||
|
||||
return (
|
||||
<PanelFrame
|
||||
title="Watch Log"
|
||||
eyebrow="LIVE PTY FEED"
|
||||
className="watch-panel"
|
||||
>
|
||||
<div className="watch-panel__actions">
|
||||
<PixelButton tone="amber" onClick={onClear}>
|
||||
Clear Log
|
||||
</PixelButton>
|
||||
</div>
|
||||
<div className="watch-log" ref={scrollerRef}>
|
||||
{logs.length === 0 ? (
|
||||
<div className="empty-state empty-state--small">
|
||||
<span>AWAITING PTY SIGNAL</span>
|
||||
</div>
|
||||
) : (
|
||||
logs.map((entry) => (
|
||||
<article key={entry.id} className={`log-row log-row--${entry.type}`}>
|
||||
<span className="log-row__time">{formatLogTime(entry.ts)}</span>
|
||||
<span className={`log-row__type log-row__type--${entry.type}`}>{logTypeLabel(entry.type)}</span>
|
||||
<pre className="log-row__message">{entry.message}</pre>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PanelFrame>
|
||||
);
|
||||
}
|
||||
27
web/src/hooks/useLogs.js
Normal file
27
web/src/hooks/useLogs.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useLogs(socket) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEntry = (entry) => setLogs((current) => [...current, entry]);
|
||||
const handleSnapshot = (entries) => setLogs(entries ?? []);
|
||||
|
||||
socket.on("log:entry", handleEntry);
|
||||
socket.on("log:snapshot", handleSnapshot);
|
||||
|
||||
return () => {
|
||||
socket.off("log:entry", handleEntry);
|
||||
socket.off("log:snapshot", handleSnapshot);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
function clearLogs() {
|
||||
socket.emit("logs:clear", {}, () => {});
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
clearLogs
|
||||
};
|
||||
}
|
||||
69
web/src/hooks/useSession.js
Normal file
69
web/src/hooks/useSession.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const initialState = {
|
||||
status: "idle",
|
||||
startedAt: null,
|
||||
teamActivated: false,
|
||||
lastError: null,
|
||||
runtime: {
|
||||
anthropicModel: "",
|
||||
anthropicBaseUrl: "",
|
||||
activeKey: "pro"
|
||||
}
|
||||
};
|
||||
|
||||
export function useSession(socket) {
|
||||
const [session, setSession] = useState(initialState);
|
||||
const [chat, setChat] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const handleState = (value) => setSession(value);
|
||||
const handleChunk = ({ chunk }) => setChat((current) => current + chunk);
|
||||
const handleSnapshot = ({ content }) => setChat(content ?? "");
|
||||
const handleReset = () => setChat("");
|
||||
const handleError = ({ message }) => setError(message);
|
||||
|
||||
socket.on("session:state", handleState);
|
||||
socket.on("chat:chunk", handleChunk);
|
||||
socket.on("chat:snapshot", handleSnapshot);
|
||||
socket.on("chat:reset", handleReset);
|
||||
socket.on("session:error", handleError);
|
||||
|
||||
return () => {
|
||||
socket.off("session:state", handleState);
|
||||
socket.off("chat:chunk", handleChunk);
|
||||
socket.off("chat:snapshot", handleSnapshot);
|
||||
socket.off("chat:reset", handleReset);
|
||||
socket.off("session:error", handleError);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
function emitWithAck(event, payload = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit(event, payload, (response) => {
|
||||
if (!response?.ok) {
|
||||
const message = response?.error ?? "Unknown socket error";
|
||||
setError(message);
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
chat,
|
||||
error,
|
||||
clearError: () => setError(""),
|
||||
startSession: () => emitWithAck("session:start"),
|
||||
stopSession: () => emitWithAck("session:stop"),
|
||||
activateTeam: () => emitWithAck("team:activate"),
|
||||
sendPrompt: (prompt) => emitWithAck("prompt:send", { prompt }),
|
||||
resizeTerminal: (cols, rows) => socket.emit("terminal:resize", { cols, rows })
|
||||
};
|
||||
}
|
||||
37
web/src/hooks/useSocket.js
Normal file
37
web/src/hooks/useSocket.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
let sharedSocket = null;
|
||||
|
||||
function getSocket() {
|
||||
if (!sharedSocket) {
|
||||
sharedSocket = io("/", {
|
||||
transports: ["websocket", "polling"]
|
||||
});
|
||||
}
|
||||
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
const [socket] = useState(() => getSocket());
|
||||
const [connected, setConnected] = useState(socket.connected);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConnect = () => setConnected(true);
|
||||
const handleDisconnect = () => setConnected(false);
|
||||
|
||||
socket.on("connect", handleConnect);
|
||||
socket.on("disconnect", handleDisconnect);
|
||||
|
||||
return () => {
|
||||
socket.off("connect", handleConnect);
|
||||
socket.off("disconnect", handleDisconnect);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return {
|
||||
socket,
|
||||
connected
|
||||
};
|
||||
}
|
||||
28
web/src/lib/formatLogLine.js
Normal file
28
web/src/lib/formatLogLine.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export function formatLogTime(value) {
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
} catch {
|
||||
return "--:--:--";
|
||||
}
|
||||
}
|
||||
|
||||
export function logTypeLabel(type) {
|
||||
switch (type) {
|
||||
case "system":
|
||||
return "SYS";
|
||||
case "input":
|
||||
return "IN";
|
||||
case "output":
|
||||
return "OUT";
|
||||
case "error":
|
||||
return "ERR";
|
||||
case "lifecycle":
|
||||
return "LIFE";
|
||||
default:
|
||||
return "LOG";
|
||||
}
|
||||
}
|
||||
153
web/src/lib/teamFeed.js
Normal file
153
web/src/lib/teamFeed.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const TEAM_MEMBERS = [
|
||||
{ id: "mazlum", name: "Mazlum", role: "Team Lead", icon: "🎩" },
|
||||
{ id: "berkecan", name: "Berkecan", role: "Frontend Developer", icon: "💻" },
|
||||
{ id: "simsar", name: "Simsar", role: "Backend Developer", icon: "⚙️" },
|
||||
{ id: "aybuke", name: "Aybuke", role: "UI/UX Designer", icon: "🎨" },
|
||||
{ id: "ive", name: "Ive", role: "iOS Developer", icon: "📱" },
|
||||
{ id: "irgatov", name: "Irgatov", role: "Trainee", icon: "☕" }
|
||||
];
|
||||
|
||||
function normalizeSpeaker(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const memberMap = new Map(TEAM_MEMBERS.map((member) => [normalizeSpeaker(member.name), member]));
|
||||
|
||||
function isNoiseLine(line) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
/^╭|^╰|^│|^─/.test(trimmed),
|
||||
/^❯/.test(trimmed),
|
||||
/^⏵⏵/.test(trimmed),
|
||||
/^⏺/.test(trimmed),
|
||||
/^✻|^✽|^✳|^✢|^· /.test(trimmed),
|
||||
/^Auth conflict:/i.test(trimmed),
|
||||
/^unset ANTHROPIC_API_KEY/i.test(trimmed),
|
||||
/^Tips for getting/i.test(trimmed),
|
||||
/^Recent activity/i.test(trimmed),
|
||||
/^No recent activity/i.test(trimmed),
|
||||
/^glm-5/i.test(trimmed),
|
||||
/^Org$/i.test(trimmed),
|
||||
/^Press up to edit/i.test(trimmed),
|
||||
/^Deliberating/i.test(trimmed),
|
||||
/^Cultivating/i.test(trimmed),
|
||||
/^Sistem yonlendirmesi:/i.test(trimmed),
|
||||
/^Hedef kisi:/i.test(trimmed),
|
||||
/^Yalnizca /i.test(trimmed),
|
||||
/^Cevap zorunlu/i.test(trimmed),
|
||||
/^Baska hicbir/i.test(trimmed),
|
||||
/^Kullanici mesaji:/i.test(trimmed),
|
||||
/^Takim ici /i.test(trimmed),
|
||||
/^Her cevap /i.test(trimmed),
|
||||
/^Etiketsiz cevap /i.test(trimmed),
|
||||
/^Gecerli ornek:/i.test(trimmed),
|
||||
/^Kullanici tek bir kisiye/i.test(trimmed),
|
||||
/^Kullanici tum takima/i.test(trimmed),
|
||||
/^Her ekip uyesi/i.test(trimmed),
|
||||
/^Ilk cevap olarak/i.test(trimmed),
|
||||
/^Bu ilk cevap/i.test(trimmed),
|
||||
/^\(erkek\)|^\(disi\)/i.test(trimmed)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
function shouldBreakCurrentEntry(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
isNoiseLine(trimmed),
|
||||
/^[-=]{4,}$/.test(trimmed),
|
||||
/^>/.test(trimmed),
|
||||
/^Kullanici /i.test(trimmed),
|
||||
/^Mazlum nasilsin\?/i.test(trimmed),
|
||||
/^[A-Za-zÀ-ÿ]+,/.test(trimmed)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
function isContinuationLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
/^[A-Za-zÀ-ÿ0-9ÇĞİÖŞÜçğıöşü"'`(]/.test(trimmed),
|
||||
/^[.!?…]/.test(trimmed),
|
||||
/^💪|^😊|^🚀|^☕|^🎨|^📱/.test(trimmed)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
function dedupeMessages(messages) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const key = `${message.speaker}::${message.text}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
result.push(message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseTeamFeed(chat) {
|
||||
const entries = [];
|
||||
let currentEntry = null;
|
||||
|
||||
for (const rawLine of String(chat ?? "").split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
const speakerMatch = line.match(/^[•*\-⏺]?\s*([A-Za-zÀ-ÿ]+):\s*(.*)$/);
|
||||
|
||||
if (speakerMatch) {
|
||||
const member = memberMap.get(normalizeSpeaker(speakerMatch[1]));
|
||||
if (!member) {
|
||||
currentEntry = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentEntry = {
|
||||
id: `${member.id}_${entries.length}_${Date.now()}`,
|
||||
speaker: member.name,
|
||||
text: speakerMatch[2] || ""
|
||||
};
|
||||
entries.push(currentEntry);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldBreakCurrentEntry(line)) {
|
||||
currentEntry = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isContinuationLine(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentEntry.text = currentEntry.text ? `${currentEntry.text}\n${line}` : line;
|
||||
}
|
||||
|
||||
return TEAM_MEMBERS.map((member) => ({
|
||||
...member,
|
||||
messages: dedupeMessages(entries.filter((entry) => entry.speaker === member.name))
|
||||
}));
|
||||
}
|
||||
|
||||
export { TEAM_MEMBERS };
|
||||
13
web/src/main.jsx
Normal file
13
web/src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./styles/reset.css";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/effects.css";
|
||||
import "./styles/app.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
404
web/src/styles/app.css
Normal file
404
web/src/styles/app.css
Normal file
@@ -0,0 +1,404 @@
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.app-shell__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-shell__eyebrow,
|
||||
.panel-frame__eyebrow,
|
||||
.prompt-composer__label,
|
||||
.status-strip__label {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.app-shell h1,
|
||||
.panel-frame__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-shell h1 {
|
||||
font-size: clamp(1.3rem, 2vw, 2rem);
|
||||
}
|
||||
|
||||
.app-shell__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-shell__meta span {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid var(--border-mid);
|
||||
background: rgba(15, 22, 17, 0.8);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.shell-frame {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
border: 4px solid #374739;
|
||||
background: linear-gradient(180deg, #2a352b 0%, #161d17 100%);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.shell-frame__bezel {
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shell-frame__screen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 78vh;
|
||||
padding: 18px;
|
||||
border: 4px solid #081108;
|
||||
background:
|
||||
radial-gradient(circle at center, rgba(34, 65, 43, 0.35), transparent 50%),
|
||||
linear-gradient(180deg, rgba(11, 18, 12, 0.98) 0%, rgba(7, 12, 8, 0.98) 100%);
|
||||
}
|
||||
|
||||
.shell-frame__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.status-strip__cell,
|
||||
.panel-frame,
|
||||
.prompt-composer,
|
||||
.error-banner {
|
||||
border: 3px solid var(--border-dark);
|
||||
box-shadow: inset 0 0 0 2px var(--border-light), var(--shadow-panel);
|
||||
background: linear-gradient(180deg, rgba(26, 34, 29, 0.95) 0%, rgba(14, 19, 16, 0.95) 100%);
|
||||
}
|
||||
|
||||
.status-strip__cell {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.status-strip__cell strong {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.is-green {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.is-red {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.is-cyan {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.is-amber {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.session-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pixel-button {
|
||||
position: relative;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
background: transparent;
|
||||
min-width: 156px;
|
||||
}
|
||||
|
||||
.pixel-button span {
|
||||
display: block;
|
||||
padding: 14px 16px;
|
||||
border: 3px solid var(--border-dark);
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.06);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.pixel-button:hover span {
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.pixel-button:active span {
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
.pixel-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.pixel-button--green span {
|
||||
background: linear-gradient(180deg, rgba(55, 112, 68, 0.9), rgba(20, 58, 28, 0.96));
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.pixel-button--cyan span {
|
||||
background: linear-gradient(180deg, rgba(35, 104, 112, 0.9), rgba(16, 49, 54, 0.96));
|
||||
color: #d8feff;
|
||||
}
|
||||
|
||||
.pixel-button--red span {
|
||||
background: linear-gradient(180deg, rgba(125, 56, 56, 0.9), rgba(63, 21, 21, 0.96));
|
||||
color: #ffe6e6;
|
||||
}
|
||||
|
||||
.pixel-button--amber span {
|
||||
background: linear-gradient(180deg, rgba(132, 95, 37, 0.9), rgba(70, 45, 12, 0.96));
|
||||
color: #fff2d5;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.console-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.9fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.console-grid__main,
|
||||
.console-grid__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-frame__header {
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.panel-frame__body {
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.chat-stream,
|
||||
.team-card__body {
|
||||
min-height: 420px;
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--border-mid);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(4, 8, 5, 0.88) 0%, rgba(8, 12, 9, 0.92) 100%);
|
||||
}
|
||||
|
||||
.chat-stream pre,
|
||||
.team-message pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.chat-stream pre {
|
||||
color: var(--accent-green);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 100%;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.empty-state--small {
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.prompt-composer {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.prompt-composer textarea {
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
border: 3px solid var(--border-dark);
|
||||
box-shadow: inset 0 0 0 2px var(--border-mid);
|
||||
background: rgba(5, 10, 6, 0.95);
|
||||
color: var(--text-main);
|
||||
padding: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.prompt-composer textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.prompt-composer__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.team-board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-card {
|
||||
border: 2px solid var(--border-mid);
|
||||
background: linear-gradient(180deg, rgba(10, 16, 11, 0.92), rgba(8, 12, 9, 0.96));
|
||||
box-shadow: inset 0 0 0 1px rgba(114, 255, 132, 0.08);
|
||||
}
|
||||
|
||||
.team-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px dashed rgba(70, 121, 84, 0.32);
|
||||
}
|
||||
|
||||
.team-card__identity {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.team-card__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: rgba(35, 52, 41, 0.72);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.team-card__identity h3,
|
||||
.team-card__identity p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.team-card__identity h3 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.team-card__identity p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.team-card__count {
|
||||
display: inline-flex;
|
||||
min-width: 28px;
|
||||
justify-content: center;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.team-card__body {
|
||||
min-height: 124px;
|
||||
max-height: 190px;
|
||||
}
|
||||
|
||||
.team-message {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px dashed rgba(70, 121, 84, 0.22);
|
||||
}
|
||||
|
||||
.team-message:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.team-message__speaker {
|
||||
color: var(--accent-cyan);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.team-message pre {
|
||||
color: var(--text-main);
|
||||
line-height: 1.45;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.status-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.console-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.app-shell__header,
|
||||
.prompt-composer__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shell-frame__screen {
|
||||
min-height: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
71
web/src/styles/effects.css
Normal file
71
web/src/styles/effects.css
Normal file
@@ -0,0 +1,71 @@
|
||||
@keyframes screen-flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: var(--glow-green);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(114, 255, 132, 0.26);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
.shell-frame__scanlines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 50%, rgba(0, 0, 0, 0.08) 50%);
|
||||
background-size: 100% 4px;
|
||||
mix-blend-mode: soft-light;
|
||||
pointer-events: none;
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.shell-frame__noise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px),
|
||||
radial-gradient(circle at 80% 35%, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px),
|
||||
radial-gradient(circle at 40% 80%, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px);
|
||||
background-size: 9px 9px, 13px 13px, 11px 11px;
|
||||
opacity: 0.08;
|
||||
animation: screen-flicker 3s steps(4) infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pixel-button,
|
||||
.panel-frame,
|
||||
.status-strip,
|
||||
.prompt-composer textarea,
|
||||
.error-banner {
|
||||
animation: pulse-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-state span:last-child::after {
|
||||
content: "_";
|
||||
display: inline-block;
|
||||
margin-left: 0.25rem;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
}
|
||||
20
web/src/styles/reset.css
Normal file
20
web/src/styles/reset.css
Normal file
@@ -0,0 +1,20 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
36
web/src/styles/theme.css
Normal file
36
web/src/styles/theme.css
Normal file
@@ -0,0 +1,36 @@
|
||||
:root {
|
||||
--bg-0: #0a0f0a;
|
||||
--bg-1: #111714;
|
||||
--bg-2: #18221b;
|
||||
--panel: rgba(18, 28, 22, 0.88);
|
||||
--panel-hi: rgba(30, 46, 36, 0.9);
|
||||
--bezel: #283229;
|
||||
|
||||
--text-main: #dfffe2;
|
||||
--text-dim: #92c69a;
|
||||
--text-muted: #5d7a63;
|
||||
|
||||
--accent-green: #72ff84;
|
||||
--accent-amber: #ffbf61;
|
||||
--accent-cyan: #70f3ff;
|
||||
--accent-red: #ff6b6b;
|
||||
|
||||
--border-dark: #071009;
|
||||
--border-mid: #22412b;
|
||||
--border-light: #467954;
|
||||
--shadow-panel: 0 0 0 2px rgba(5, 9, 6, 0.7), 0 0 0 4px rgba(59, 92, 68, 0.25), 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
--glow-green: 0 0 24px rgba(114, 255, 132, 0.18);
|
||||
|
||||
--font-display: "Press Start 2P", "Courier New", monospace;
|
||||
--font-body: "IBM Plex Mono", "Courier Prime", "Lucida Console", monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(112, 243, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(255, 191, 97, 0.08), transparent 26%),
|
||||
linear-gradient(180deg, #0b0f0c 0%, #070a08 100%);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
Reference in New Issue
Block a user