Compare commits
2 Commits
9294028fb2
...
b470f9e6cd
| Author | SHA1 | Date | |
|---|---|---|---|
| b470f9e6cd | |||
| 68d5c2afea |
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules/
|
||||
web/dist/
|
||||
dist/
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
logs/
|
||||
*.log
|
||||
*.jsonl
|
||||
|
||||
.DS_Store
|
||||
.vite/
|
||||
.cache/
|
||||
coverage/
|
||||
3265
package-lock.json
generated
Normal file
3265
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "startup-claude-retro-console",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:dev:server\" \"npm:dev:web\"",
|
||||
"dev:server": "node --watch server/index.js",
|
||||
"dev:web": "vite --config web/vite.config.js",
|
||||
"build": "vite build --config web/vite.config.js",
|
||||
"start": "NODE_ENV=production node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"node-pty": "^1.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
15
server/bootstrapPrompt.js
vendored
Normal file
15
server/bootstrapPrompt.js
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
export function buildBootstrapPrompt() {
|
||||
return [
|
||||
'Team agent modunu aktif et.',
|
||||
'Team icerisindeki roller -> Team Lead: "Mazlum" (erkek), Frontend Developer: "Berkecan" (erkek), Backend Developer: "Simsar" (erkek), UI/UX Designer: "Aybuke" (disi), iOS Developer: "Ive" (erkek) ve takim uyelerine kahveleri getirmesi icin Trainee: "Irgatov" (erkek).',
|
||||
"Bu takim yapisini aynen koru.",
|
||||
"Takim ici tum mesajlarda konusan kisi zorunlu olarak ad etiketiyle baslasin.",
|
||||
"Her cevap yalnizca su formatla baslasin: `Mazlum:` veya `Berkecan:` veya `Simsar:` veya `Aybuke:` veya `Ive:` veya `Irgatov:`.",
|
||||
"Etiketsiz cevap verme. `Ben`, `Team Lead`, `Frontend Developer`, `UI/UX Designer`, `biz`, `takim olarak` gibi baslangiclar kullanma.",
|
||||
"Gecerli ornek: `Mazlum: Buradayim.` Gecersiz ornek: `Buradayim.`",
|
||||
"Kullanici tek bir kisiye seslenirse sadece o kisi cevap versin ve cevabi kendi ad etiketiyle baslatsin.",
|
||||
"Kullanici tum takima veya genel bir goreve seslenirse once `Mazlum:` cevap versin. Gerekirse digerleri ayri satirlarda kendi ad etiketiyle devam etsin.",
|
||||
"Her ekip uyesi her mesajda kendi sabit adini kullanir, isim degistirmez.",
|
||||
"Ilk cevap olarak yalnizca takimin hazir oldugunu ve rollerin aktiflestigini bildir. Bu ilk cevap da `Mazlum:` ile baslasin."
|
||||
].join(" ");
|
||||
}
|
||||
94
server/config.js
Normal file
94
server/config.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import dotenv from "dotenv";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
function toBool(value, fallback = false) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return String(value).toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export function getActiveApiKey() {
|
||||
const activeKey = (process.env.ACTIVE_KEY ?? "pro").toLowerCase();
|
||||
if (activeKey === "lite") {
|
||||
return process.env.API_KEY_LITE ?? "";
|
||||
}
|
||||
|
||||
return process.env.API_KEY_PRO ?? "";
|
||||
}
|
||||
|
||||
export function getRuntimeConfig() {
|
||||
const claudeBin = resolveClaudeBinary(process.env.CLAUDE_BIN ?? "claude");
|
||||
return {
|
||||
port: Number(process.env.PORT ?? 3001),
|
||||
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||
claudeBin,
|
||||
shell: process.env.CLAUDE_SHELL ?? "/bin/zsh",
|
||||
workspaceDir: process.env.CLAUDE_WORKSPACE_DIR
|
||||
? path.resolve(process.env.CLAUDE_WORKSPACE_DIR)
|
||||
: process.cwd(),
|
||||
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "",
|
||||
anthropicModel: process.env.ANTHROPIC_MODEL ?? "",
|
||||
activeKey: (process.env.ACTIVE_KEY ?? "pro").toLowerCase(),
|
||||
claudeArgs: process.env.CLAUDE_ARGS?.trim() ? process.env.CLAUDE_ARGS.trim().split(/\s+/) : ["--dangerously-skip-permissions"],
|
||||
watchLogLimit: Number(process.env.WATCH_LOG_LIMIT ?? 400),
|
||||
chatChunkLimit: Number(process.env.CHAT_CHUNK_LIMIT ?? 2000),
|
||||
logToConsole: toBool(process.env.LOG_TO_CONSOLE, true)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClaudeBinary(rawValue) {
|
||||
const candidates = [];
|
||||
|
||||
if (rawValue) {
|
||||
candidates.push(rawValue);
|
||||
}
|
||||
|
||||
candidates.push("/Users/wisecolt-macmini/.local/bin/claude");
|
||||
candidates.push("/usr/local/bin/claude");
|
||||
candidates.push("/opt/homebrew/bin/claude");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (path.isAbsolute(candidate) && fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const pathEntries = String(process.env.PATH ?? "").split(path.delimiter);
|
||||
for (const entry of pathEntries) {
|
||||
const candidate = path.join(entry, rawValue);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
export function getClaudeEnv(config) {
|
||||
const cleanEnv = { ...process.env };
|
||||
delete cleanEnv.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
return {
|
||||
...cleanEnv,
|
||||
ANTHROPIC_API_KEY: getActiveApiKey(),
|
||||
ANTHROPIC_BASE_URL: config.anthropicBaseUrl,
|
||||
ANTHROPIC_MODEL: config.anthropicModel,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor"
|
||||
};
|
||||
}
|
||||
|
||||
export function getPublicRuntimeConfig(config) {
|
||||
return {
|
||||
claudeBin: config.claudeBin,
|
||||
anthropicBaseUrl: config.anthropicBaseUrl,
|
||||
anthropicModel: config.anthropicModel,
|
||||
activeKey: config.activeKey,
|
||||
workspaceDir: config.workspaceDir
|
||||
};
|
||||
}
|
||||
51
server/index.js
Normal file
51
server/index.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { Server } from "socket.io";
|
||||
import { getPublicRuntimeConfig, getRuntimeConfig } from "./config.js";
|
||||
import { SessionManager } from "./sessionManager.js";
|
||||
import { registerSocketHandlers } from "./socketHandlers.js";
|
||||
|
||||
const config = getRuntimeConfig();
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*"
|
||||
}
|
||||
});
|
||||
|
||||
const sessionManager = new SessionManager({ io, config });
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webDistPath = path.resolve(__dirname, "../web/dist");
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
runtime: getPublicRuntimeConfig(config),
|
||||
session: sessionManager.getState()
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/session/state", (req, res) => {
|
||||
res.json({
|
||||
state: sessionManager.getState(),
|
||||
logs: sessionManager.getLogSnapshot(),
|
||||
chat: sessionManager.getChatSnapshot()
|
||||
});
|
||||
});
|
||||
|
||||
if (config.nodeEnv === "production") {
|
||||
app.use(express.static(webDistPath));
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(webDistPath, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
registerSocketHandlers(io, sessionManager);
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`Retro console server listening on http://localhost:${config.port}`);
|
||||
});
|
||||
40
server/logService.js
Normal file
40
server/logService.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export class LogService {
|
||||
constructor(limit = 400, logger = console) {
|
||||
this.limit = limit;
|
||||
this.logger = logger;
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
createEntry(type, message, meta = {}) {
|
||||
return {
|
||||
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type,
|
||||
message,
|
||||
meta,
|
||||
ts: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
push(type, message, meta = {}) {
|
||||
const entry = this.createEntry(type, message, meta);
|
||||
this.entries.push(entry);
|
||||
|
||||
if (this.entries.length > this.limit) {
|
||||
this.entries.splice(0, this.entries.length - this.limit);
|
||||
}
|
||||
|
||||
if (this.logger && type !== "output") {
|
||||
this.logger.info(`[${entry.type}] ${entry.message}`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.entries = [];
|
||||
}
|
||||
}
|
||||
167
server/ptyService.js
Normal file
167
server/ptyService.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const TYPE_CHUNK_SIZE = 18;
|
||||
const TYPE_CHUNK_DELAY_MS = 22;
|
||||
|
||||
function shellEscape(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function buildLaunchCommand(command, args, env) {
|
||||
const exports = Object.entries(env)
|
||||
.filter(([, value]) => value != null && value !== "")
|
||||
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
|
||||
.join("; ");
|
||||
|
||||
const commandPart = [command, ...args].map((part) => shellEscape(part)).join(" ");
|
||||
return `${exports}; exec ${commandPart}`;
|
||||
}
|
||||
|
||||
async function runTmux(args) {
|
||||
return execFileAsync("/opt/homebrew/bin/tmux", args);
|
||||
}
|
||||
|
||||
async function typeLikeHuman(sessionName, text) {
|
||||
for (let index = 0; index < text.length; index += TYPE_CHUNK_SIZE) {
|
||||
const chunk = text.slice(index, index + TYPE_CHUNK_SIZE);
|
||||
if (chunk) {
|
||||
await runTmux(["send-keys", "-t", sessionName, "-l", chunk]);
|
||||
await wait(TYPE_CHUNK_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PtyService {
|
||||
constructor({ cwd, env }) {
|
||||
this.cwd = cwd;
|
||||
this.env = env;
|
||||
this.sessionName = null;
|
||||
this.pollTimer = null;
|
||||
this.lastSnapshot = "";
|
||||
this.onData = null;
|
||||
this.onExit = null;
|
||||
}
|
||||
|
||||
async start({ command, args = [], onData, onExit }) {
|
||||
if (this.sessionName) {
|
||||
throw new Error("PTY session is already running");
|
||||
}
|
||||
|
||||
this.onData = onData;
|
||||
this.onExit = onExit;
|
||||
this.lastSnapshot = "";
|
||||
this.sessionName = `retro_claude_${Date.now()}`;
|
||||
const launchCommand = buildLaunchCommand(command, args, this.env);
|
||||
|
||||
await runTmux([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
this.sessionName,
|
||||
"-c",
|
||||
this.cwd,
|
||||
"/bin/zsh",
|
||||
"-lc",
|
||||
launchCommand
|
||||
]);
|
||||
|
||||
this.startPolling();
|
||||
}
|
||||
|
||||
async write(input) {
|
||||
if (!this.sessionName) {
|
||||
throw new Error("No active PTY session");
|
||||
}
|
||||
|
||||
const normalized = String(input ?? "");
|
||||
const chunks = normalized.split(/\r\n|\r|\n/);
|
||||
const shouldPressEnterAtEnd = /[\r\n]$/.test(normalized);
|
||||
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
const chunk = chunks[index];
|
||||
if (chunk) {
|
||||
await typeLikeHuman(this.sessionName, chunk);
|
||||
}
|
||||
|
||||
if (index < chunks.length - 1) {
|
||||
await runTmux(["send-keys", "-t", this.sessionName, "Enter"]);
|
||||
await wait(35);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPressEnterAtEnd) {
|
||||
await wait(60);
|
||||
await runTmux(["send-keys", "-t", this.sessionName, "Enter"]);
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols, rows) {
|
||||
void cols;
|
||||
void rows;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.sessionName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionName = this.sessionName;
|
||||
this.stopPolling();
|
||||
this.sessionName = null;
|
||||
this.lastSnapshot = "";
|
||||
await runTmux(["kill-session", "-t", sessionName]).catch(() => {});
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return Boolean(this.sessionName);
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
this.stopPolling();
|
||||
this.pollTimer = setInterval(async () => {
|
||||
if (!this.sessionName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await runTmux(["capture-pane", "-p", "-t", this.sessionName, "-S", "-200"]);
|
||||
const snapshot = stdout ?? "";
|
||||
if (!snapshot || snapshot === this.lastSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = snapshot.startsWith(this.lastSnapshot)
|
||||
? snapshot.slice(this.lastSnapshot.length)
|
||||
: snapshot;
|
||||
|
||||
this.lastSnapshot = snapshot;
|
||||
this.onData?.(payload);
|
||||
} catch (error) {
|
||||
const message = String(error.stderr ?? error.message ?? "");
|
||||
if (message.includes("can't find session")) {
|
||||
const previous = this.sessionName;
|
||||
this.stopPolling();
|
||||
this.sessionName = null;
|
||||
this.lastSnapshot = "";
|
||||
this.onExit?.({
|
||||
exitCode: 0,
|
||||
signal: 0,
|
||||
error: previous ? undefined : "tmux session missing"
|
||||
});
|
||||
} else {
|
||||
this.onData?.(`\n[tmux-error] ${message}\n`);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
234
server/sessionManager.js
Normal file
234
server/sessionManager.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { buildBootstrapPrompt } from "./bootstrapPrompt.js";
|
||||
import { LogService } from "./logService.js";
|
||||
import { PtyService } from "./ptyService.js";
|
||||
import { getClaudeEnv, getPublicRuntimeConfig } from "./config.js";
|
||||
import { findMentionedMember } from "./teamConfig.js";
|
||||
|
||||
function cleanChunk(value) {
|
||||
return stripAnsi(value).replace(/\r/g, "");
|
||||
}
|
||||
|
||||
function isLikelyFollowUp(prompt) {
|
||||
const normalized = String(prompt ?? "").trim();
|
||||
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
|
||||
|
||||
return [
|
||||
wordCount <= 8,
|
||||
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|şekersiz|detaylandir|detaylandır|devam|peki|neden|nasil|nasıl)\b/i.test(normalized)
|
||||
].some(Boolean);
|
||||
}
|
||||
|
||||
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
|
||||
const explicitTarget = findMentionedMember(prompt);
|
||||
const targetMember = explicitTarget ?? (lastDirectedMember && isLikelyFollowUp(prompt) ? lastDirectedMember : null);
|
||||
|
||||
if (!targetMember) {
|
||||
return {
|
||||
targetMember: null,
|
||||
routedPrompt: [
|
||||
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
|
||||
"Bu mesaj genel bir gorev veya genel konusmadir.",
|
||||
"Cevabi once Mazlum baslatsin.",
|
||||
"Konusan herkes ad etiketi kullansin.",
|
||||
"[KULLANICI MESAJI]",
|
||||
prompt
|
||||
].join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
targetMember,
|
||||
routedPrompt: [
|
||||
"[YONLENDIRME NOTU - CEVAPTA TEKRAR ETME]",
|
||||
`Bu mesaj ${targetMember.name} icindir.`,
|
||||
`Yalnizca ${targetMember.name} cevap versin.`,
|
||||
`Cevap \`${targetMember.name}:\` ile baslasin.`,
|
||||
"[KULLANICI MESAJI]",
|
||||
prompt
|
||||
].join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
constructor({ io, config }) {
|
||||
this.io = io;
|
||||
this.config = config;
|
||||
this.logService = new LogService(config.watchLogLimit, config.logToConsole ? console : null);
|
||||
this.ptyService = null;
|
||||
this.chatOutput = "";
|
||||
this.lastDirectedMember = null;
|
||||
this.state = {
|
||||
status: "idle",
|
||||
startedAt: null,
|
||||
teamActivated: false,
|
||||
lastError: null,
|
||||
runtime: getPublicRuntimeConfig(config)
|
||||
};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
...this.state,
|
||||
runtime: getPublicRuntimeConfig(this.config)
|
||||
};
|
||||
}
|
||||
|
||||
getLogSnapshot() {
|
||||
return this.logService.snapshot();
|
||||
}
|
||||
|
||||
getChatSnapshot() {
|
||||
return this.chatOutput;
|
||||
}
|
||||
|
||||
emitState() {
|
||||
this.io.emit("session:state", this.getState());
|
||||
}
|
||||
|
||||
emitLog(entry) {
|
||||
this.io.emit("log:entry", entry);
|
||||
}
|
||||
|
||||
emitChat(chunk) {
|
||||
this.io.emit("chat:chunk", { chunk });
|
||||
}
|
||||
|
||||
addLog(type, message, meta = {}) {
|
||||
const entry = this.logService.push(type, message, meta);
|
||||
this.emitLog(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
setState(patch) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...patch
|
||||
};
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.ptyService?.isRunning()) {
|
||||
throw new Error("Session is already running");
|
||||
}
|
||||
|
||||
this.chatOutput = "";
|
||||
this.lastDirectedMember = null;
|
||||
this.logService.clear();
|
||||
this.io.emit("chat:reset");
|
||||
|
||||
this.ptyService = new PtyService({
|
||||
cwd: this.config.workspaceDir,
|
||||
env: getClaudeEnv(this.config)
|
||||
});
|
||||
|
||||
this.setState({
|
||||
status: "starting",
|
||||
startedAt: new Date().toISOString(),
|
||||
teamActivated: false,
|
||||
lastError: null
|
||||
});
|
||||
|
||||
this.addLog("lifecycle", `Starting Claude session in ${this.config.workspaceDir}`);
|
||||
|
||||
try {
|
||||
await this.ptyService.start({
|
||||
command: this.config.claudeBin,
|
||||
args: this.config.claudeArgs,
|
||||
onData: (chunk) => this.handlePtyData(chunk),
|
||||
onExit: (event) => this.handlePtyExit(event)
|
||||
});
|
||||
this.setState({ status: "running" });
|
||||
this.addLog("lifecycle", `Claude process started with binary ${this.config.claudeBin}`);
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
status: "error",
|
||||
lastError: error.message
|
||||
});
|
||||
this.addLog("error", error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.ptyService?.isRunning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addLog("lifecycle", "Stopping Claude session");
|
||||
await this.ptyService.stop();
|
||||
this.lastDirectedMember = null;
|
||||
this.setState({
|
||||
status: "stopped",
|
||||
teamActivated: false
|
||||
});
|
||||
}
|
||||
|
||||
async sendPrompt(prompt) {
|
||||
if (!this.ptyService?.isRunning()) {
|
||||
throw new Error("Session is not running");
|
||||
}
|
||||
|
||||
const { routedPrompt, targetMember } = buildRoutedPrompt(prompt, this.lastDirectedMember);
|
||||
const input = `${routedPrompt}\r`;
|
||||
this.lastDirectedMember = targetMember ?? null;
|
||||
this.addLog("input", prompt);
|
||||
await this.ptyService.write(input);
|
||||
}
|
||||
|
||||
async sendRawPrompt(prompt, meta = {}) {
|
||||
if (!this.ptyService?.isRunning()) {
|
||||
throw new Error("Session is not running");
|
||||
}
|
||||
|
||||
const input = `${prompt}\r`;
|
||||
this.addLog("input", meta.label ?? prompt, meta);
|
||||
await this.ptyService.write(input);
|
||||
}
|
||||
|
||||
async activateTeam() {
|
||||
const prompt = buildBootstrapPrompt();
|
||||
this.lastDirectedMember = null;
|
||||
await this.sendRawPrompt(prompt, { label: "[bootstrap] Team activation prompt sent" });
|
||||
this.setState({ teamActivated: true });
|
||||
this.addLog("system", "Team activation prompt sent");
|
||||
}
|
||||
|
||||
resize({ cols, rows }) {
|
||||
this.ptyService?.resize(cols, rows);
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
this.logService.clear();
|
||||
this.io.emit("log:snapshot", []);
|
||||
this.addLog("system", "Watch log cleared");
|
||||
}
|
||||
|
||||
handlePtyData(chunk) {
|
||||
const clean = cleanChunk(chunk);
|
||||
if (!clean) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatOutput += clean;
|
||||
if (this.chatOutput.length > this.config.chatChunkLimit * 20) {
|
||||
this.chatOutput = this.chatOutput.slice(-this.config.chatChunkLimit * 20);
|
||||
}
|
||||
|
||||
this.emitChat(clean);
|
||||
this.addLog("output", clean);
|
||||
}
|
||||
|
||||
handlePtyExit(event) {
|
||||
const exitCode = event?.exitCode ?? 0;
|
||||
const signal = event?.signal ?? 0;
|
||||
const detail = event?.error ? `, error=${event.error}` : "";
|
||||
this.addLog("lifecycle", `Claude session exited (code=${exitCode}, signal=${signal}${detail})`);
|
||||
this.setState({
|
||||
status: "stopped",
|
||||
teamActivated: false
|
||||
});
|
||||
this.ptyService = null;
|
||||
}
|
||||
}
|
||||
56
server/socketHandlers.js
Normal file
56
server/socketHandlers.js
Normal file
@@ -0,0 +1,56 @@
|
||||
export function registerSocketHandlers(io, sessionManager) {
|
||||
io.on("connection", (socket) => {
|
||||
socket.emit("session:state", sessionManager.getState());
|
||||
socket.emit("log:snapshot", sessionManager.getLogSnapshot());
|
||||
socket.emit("chat:snapshot", { content: sessionManager.getChatSnapshot() });
|
||||
|
||||
socket.on("session:start", async (payload, callback) => {
|
||||
try {
|
||||
await sessionManager.start();
|
||||
callback?.({ ok: true });
|
||||
} catch (error) {
|
||||
socket.emit("session:error", { message: error.message });
|
||||
callback?.({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("session:stop", async (payload, callback) => {
|
||||
try {
|
||||
await sessionManager.stop();
|
||||
callback?.({ ok: true });
|
||||
} catch (error) {
|
||||
socket.emit("session:error", { message: error.message });
|
||||
callback?.({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("team:activate", async (payload, callback) => {
|
||||
try {
|
||||
await sessionManager.activateTeam();
|
||||
callback?.({ ok: true });
|
||||
} catch (error) {
|
||||
socket.emit("session:error", { message: error.message });
|
||||
callback?.({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("prompt:send", async ({ prompt }, callback) => {
|
||||
try {
|
||||
await sessionManager.sendPrompt(prompt);
|
||||
callback?.({ ok: true });
|
||||
} catch (error) {
|
||||
socket.emit("session:error", { message: error.message });
|
||||
callback?.({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("terminal:resize", ({ cols, rows }) => {
|
||||
sessionManager.resize({ cols, rows });
|
||||
});
|
||||
|
||||
socket.on("logs:clear", (payload, callback) => {
|
||||
sessionManager.clearLogs();
|
||||
callback?.({ ok: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
31
server/teamConfig.js
Normal file
31
server/teamConfig.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const TEAM_MEMBERS = [
|
||||
{ id: "mazlum", name: "Mazlum", aliases: ["mazlum", "team lead", "lead"] },
|
||||
{ id: "berkecan", name: "Berkecan", aliases: ["berkecan", "frontend developer", "frontend"] },
|
||||
{ id: "simsar", name: "Simsar", aliases: ["simsar", "backend developer", "backend"] },
|
||||
{ id: "aybuke", name: "Aybuke", aliases: ["aybuke", "aybüke", "ui/ux designer", "designer"] },
|
||||
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios"] },
|
||||
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern"] }
|
||||
];
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function findMentionedMember(prompt) {
|
||||
const normalizedPrompt = normalizeText(prompt);
|
||||
|
||||
for (const member of TEAM_MEMBERS) {
|
||||
for (const alias of member.aliases) {
|
||||
if (normalizedPrompt.includes(normalizeText(alias))) {
|
||||
return member;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export { TEAM_MEMBERS };
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Retro Claude Console</title>
|
||||
<meta name="theme-color" content="#0a0f0a" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
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);
|
||||
}
|
||||
22
web/vite.config.js
Normal file
22
web/vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
root: "./web",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/socket.io": {
|
||||
target: "http://localhost:3001",
|
||||
ws: true
|
||||
},
|
||||
"/health": "http://localhost:3001",
|
||||
"/api": "http://localhost:3001"
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user