Compare commits

...

11 Commits

58 changed files with 8765 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
node_modules/
web/dist/
dist/
.env
.env.local
.env.*.local
logs/
*.log
*.jsonl
.DS_Store
.vite/
.cache/
coverage/

190
README.md
View File

@@ -0,0 +1,190 @@
# Retro Claude Team Console 🖥️✨
90'lar retro/pixel estetiğiyle hazırlanmış, Claude CLI oturumunu web arayüzünden yöneten deneysel bir ekip konsolu.
Amaç: tek bir web uygulaması üzerinden Claude oturumunu otomatik başlatmak, bir proje dizini seçmek, ekibi seçilen projeye bağlamak, canlı cevap akışını izlemek ve ekip üyelerinin yanıtlarını rol bazlı kartlarda görmek.
## Özellikler 🚀
- Uygulama açıldığında otomatik Claude oturumu başlatma
- `Select Project` ile aktif proje dizini seçme
- Proje seçildiğinde ekibi otomatik aktive etme
- Retro/pixel web konsol arayüzü
- Sol panelde ekip üyelerine göre ayrılmış kartlar
- Sağ panelde canlı ana akış ve prompt alanı
- Hedef kişiye göre yönlendirilmiş prompt gönderimi
- `Mazlum:`, `Simsar:`, `Aybuke:` gibi etiketli cevap formatı
- `Current Project` göstergesiyle aktif proje takibi
- `tmux` tabanlı PTY oturumu yönetimi
## Ekip Yapısı 👥
- Mazlum: Team Lead
- Berkecan: Frontend Developer
- Simsar: Backend Developer
- Aybuke: UI/UX Designer
- Ive: iOS Developer
- Irgatov: Trainee
## Teknoloji Yığını 🧰
- Node.js
- Express
- Socket.IO
- React
- Vite
- `tmux`
## Gereksinimler 📦
- Node.js
- npm
- `tmux`
- makinede erişilebilir bir `claude` binary
Kontrol etmek için:
```bash
node -v
npm -v
tmux -V
claude --version
```
## Ortam Değişkenleri 🔐
Örnek `.env`:
```env
API_KEY_PRO="..."
API_KEY_LITE="..."
ACTIVE_KEY=pro
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic"
ANTHROPIC_MODEL="glm-5"
```
İsteğe bağlı değişkenler:
```env
PORT=3001
CLAUDE_BIN=claude
CLAUDE_SHELL=/bin/zsh
CLAUDE_ARGS=--dangerously-skip-permissions
WATCH_LOG_LIMIT=400
CHAT_CHUNK_LIMIT=2000
LOG_TO_CONSOLE=true
```
## Kurulum 🛠️
```bash
npm install
```
## Geliştirme Modu ▶️
```bash
npm run dev
```
Bu komut:
- backend'i `http://localhost:3001`
- frontend'i `http://localhost:3000`
adresinde çalıştırır.
## Production Build 📦
```bash
npm run build
npm run start
```
## Kullanım Akışı 🎮
1. Uygulamayı
2. Claude oturumunun otomatik başlamasını bekle
3. `Select Project` ile proje klasörünü seç
4. Sistem seçilen projede oturumu hizalayıp ekibi otomatik aktive etsin
5. Bir ekip üyesine ya da tüm takıma prompt yaz
6. Solda rol bazlı kartları, sağda canlı ana akışı takip et
## Proje Seçimi Mantığı 📁
- `Select Project` macOS klasör seçicisini açar
- Seçilen klasör backend tarafında aktif proje olarak tutulur
- UI'da `Current Project: ...` alanında seçili path görünür
- Proje seçilmemişse `Current Project: None` görünür
- Aktif session varsa Claude oturumu seçilen proje köküne yeniden hizalanır
- Team bootstrap prompt'u seçilen proje path'iyle birlikte yeniden kurulur
- Bundan sonraki tüm prompt'lar varsayılan olarak bu proje bağlamında yorumlanır
## Prompt Davranışı 🧠
Sistem şu mantıkla çalışır:
- Uygulama açıldığında session otomatik başlar
- Proje seçilmeden takım modu tam olarak devreye girmez
- Proje seçildiğinde ekip yalnızca o proje üzerinde çalışacak şekilde yönlendirilir
- Kullanıcı mesajında bir ekip üyesinin adı geçerse prompt o kişiye yönlendirilir
- Kısa takip mesajları mümkünse son hedef kişiye bağlanır
- Yanıtların `Mazlum:` / `Simsar:` gibi isim etiketiyle başlaması zorlanır
- Sağ paneldeki kartlar bu etiketlere göre doldurulur
- Irgatov yalnızca kahve ve basit ofis/lojistik işleriyle sınırlıdır; teknik görev üstlenmez
Örnek:
```text
Mazlum nasılsın?
```
Beklenen yanıt:
```text
Mazlum: İyiyim, teşekkür ederim!
```
## Proje Yapısı 🗂️
```text
server/
bootstrapPrompt.js
config.js
index.js
logService.js
ptyService.js
sessionManager.js
socketHandlers.js
teamConfig.js
web/
index.html
vite.config.js
src/
App.jsx
components/
hooks/
lib/
styles/
```
## Bilinen Notlar ⚠️
- Claude bazen gelen yönlendirme metnini literal yorumlayabilir; routing mantığı hâlâ iyileştirilmeye açık.
- Kart parser'ı etiketli cevap formatına dayanır; format bozulursa bazı mesajlar yanlış karta düşebilir veya hiç görünmeyebilir.
- `Auth conflict` uyarısı Claude tarafındaki oturum durumuna bağlı olarak görülebilir.
- Proje seçimi şu anda macOS klasör seçici (`osascript`) üzerinden yapılır.
- Bu proje şu anda deneysel bir konsol prototipi olarak düşünülmelidir.
## Yakın Yol Haritası 🛣️
- Kart parser'ını daha akıllı hale getirmek
- Kullanıcı mesajlarını da role bazlı akışta göstermek
- Watch/debug görünümünü opsiyonel olarak geri eklemek
- Session geçmişi ve kalıcı log desteği eklemek
- Ekip içi konuşmaları daha güvenilir ayrıştırmak
## Lisans 📄
Bu repo için henüz ayrı bir lisans dosyası tanımlanmadı.

4136
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"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": {
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.17.10",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"node-pty": "^1.0.0",
"socket.io": "^4.8.1",
"strip-ansi": "^7.1.0",
"three": "^0.161.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"
}
}

26
server/bootstrapPrompt.js vendored Normal file
View File

@@ -0,0 +1,26 @@
export function buildBootstrapPrompt(projectPath = null) {
const projectContext = projectPath
? `Aktif proje kok dizini: ${projectPath}. Tum analiz, yorum, gorev parcasi ve dosya referanslarini yalnizca bu proje uzerinden yapin. Bu proje disina tasmayin.`
: "Aktif proje henuz secilmedi. Kullanici proje secene kadar dosya baglaminda varsayim yapmayin.";
return [
"Team agent modunu aktif et.",
'Takim: Team Lead: "Mazlum", Frontend Developer: "Berkecan", Backend Developer: "Simsar", UI/UX Designer: "Aybuke", iOS Developer: "Ive", Trainee: "Irgatov".',
projectContext,
"Davranis protokolu:",
"1. Her cevap mutlaka kisi adi etiketiyle baslar. Yalnizca su baslangiclar kullanilir: `Mazlum:`, `Berkecan:`, `Simsar:`, `Aybuke:`, `Ive:`, `Irgatov:`.",
"2. Kullanicinin hitabi daima `Patron`dur. Tum ekip uyeleri kullaniciya konusurken mutlaka `Patron` diye hitap eder ve saygili, olculu, profesyonel bir dil kullanir.",
"3. Ekip uyeleri kendi aralarindaki samimi dili kullaniciya karsi kullanmaz.",
"4. Tum ekip uyeleri Team Lead icin `Mazlum Bey` hitabini kullanir.",
"5. Tum ekip uyeleri UI/UX Designer icin `UI Hanim` hitabini kullanir.",
"6. Team Lead ve UI/UX Designer haric erkek ekip uyeleri kendi aralarinda gerektiginde `Frontend Kanka`, `Backend Kanka`, `iOS Kanka` gibi hitaplar kullanabilir. Bu hitaplar yalnizca ekip ici konusmalarda kullanilir.",
"7. Ekip ici konusmalar hafif esprili ve ofis ortaminda yanina gidip konusuyormus gibi dogal olabilir. Ancak mizah kisa tutulur, teknik dogruluk her zaman once gelir, gereksiz roleplay yapilmaz.",
"8. Ekip ici diyalog yalnizca gerektiginde kisa tutulur. Basit sorularda gereksiz cok kisili diyalog kurma.",
"9. Kullanici tek bir kisiye seslenirse sadece o kisi cevap verir.",
"10. Kullanici tum ekibe veya genel bir goreve seslenirse once Mazlum cevap verir. Gerekirse diger ekip uyeleri kisa katkilar yapar.",
"11. Proje tamamlandiginda, teslim ozeti veya briefing istendiginde son ozet yalnizca Mazlum tarafindan verilir.",
"12. Irgatov teknik ekip uyesi degildir. Irgatov sadece kahve, icecek, servis, basit ofis lojistigi ve yardim isleriyle ilgilenir. Kod, mimari, dosya yapisi, planlama, bug analizi, teknoloji secimi, UI/UX, backend veya iOS konularinda teknik gorus bildirmez.",
"13. Karakter davranisi teknik dogrulugun onune gecmez. Gereksiz tekrar yapma, gereksiz uzun cevap verme, yanlis ama eglenceli cevap verme.",
"14. Ilk cevap yalnizca Mazlum tarafindan verilir. Takimin aktif oldugunu, rollerin hazir oldugunu ve aktif proje dizinini bildirir. Bu ilk cevap `Mazlum:` ile baslar."
].join(" ");
}

94
server/config.js Normal file
View 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
};
}

85
server/index.js Normal file
View File

@@ -0,0 +1,85 @@
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 { selectProjectFolder } from "./projectPicker.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.use(express.json());
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()
});
});
app.post("/api/project/select", async (req, res) => {
try {
const selectedPath = req.body?.projectPath ? String(req.body.projectPath) : await selectProjectFolder();
await sessionManager.setProjectPath(selectedPath);
res.json({
ok: true,
projectPath: sessionManager.getState().currentProjectPath
});
} catch (error) {
res.status(500).json({
ok: false,
error: error.message
});
}
});
app.post("/api/project/clear", async (req, res) => {
try {
await sessionManager.setProjectPath(null);
res.json({
ok: true,
projectPath: sessionManager.getState().currentProjectPath
});
} catch (error) {
res.status(500).json({
ok: false,
error: error.message
});
}
});
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
View 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 = [];
}
}

10
server/projectPicker.js Normal file
View File

@@ -0,0 +1,10 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export async function selectProjectFolder() {
const script = 'POSIX path of (choose folder with prompt "Select project folder")';
const { stdout } = await execFileAsync("/usr/bin/osascript", ["-e", script]);
return String(stdout ?? "").trim();
}

167
server/ptyService.js Normal file
View 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;
}
}
}

362
server/sessionManager.js Normal file
View File

@@ -0,0 +1,362 @@
import fs from "fs";
import path from "path";
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, findMentionedMembers } from "./teamConfig.js";
function cleanChunk(value) {
return stripAnsi(value).replace(/\r/g, "");
}
function normalizeText(value) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function isLikelyFollowUp(prompt) {
const normalized = normalizeText(prompt);
const wordCount = normalized.split(/\s+/).filter(Boolean).length;
return [
wordCount <= 8,
/^(evet|hayir|tamam|olur|olsun|sade|sekersiz|detaylandir|detaylandir|devam|peki|neden|nasil|biraz ac|kisalt|ornek ver)\b/i.test(normalized)
].some(Boolean);
}
function isBriefingRequest(prompt) {
const normalized = normalizeText(prompt);
return [
"brief",
"briefing",
"ozet",
"durum raporu",
"tamamlandi mi",
"teslim durumu",
"son durum",
"rapor ver",
"breef"
].some((token) => normalized.includes(token));
}
function isCoordinationRequest(prompt) {
const normalized = normalizeText(prompt);
return [
"kendi aranizda konusun",
"aranizda konusun",
"toplanin",
"koordine olun",
"birbirinizle konusun",
"tartisin",
"degerlendirin",
"ekipce karar verin",
"kendi aralarinizda",
"kendi aranizda"
].some((token) => normalized.includes(token));
}
function buildGeneralPrompt(prompt) {
return {
mode: "general",
targetMember: null,
routedPrompt: `Yonlendirme notu: Bu mesaj tum ekibe yoneliktir. Once Mazlum cevap versin. Gerekirse diger ekip uyeleri kendi ad etiketiyle kisa katkilar yapsin. Kullaniciya konusurken herkes Patron diye hitap etsin. Ekip ici diyalog sadece gerekiyorsa kisa olsun. Gereksiz roleplay yapma. Sonuc net ve uygulanabilir olsun. Kullanici mesaji: ${prompt}`
};
}
function buildBriefingPrompt(prompt) {
return {
mode: "briefing",
targetMember: null,
routedPrompt: `Yonlendirme notu: Bu mesaj proje ozeti veya teslim briefigi gerektiriyor. Son ozet yalnizca Mazlum tarafindan verilsin. Cevap Mazlum: ile baslasin. Kullaniciya mutlaka Patron diye hitap et. Ozet duzenli, yonetsel ve net olsun. Gerekirse yapilanlar, kalan riskler ve sonraki adimlar kisaca belirtilsin. Diger ekip uyeleri yalnizca zorunluysa kisa katkida bulunsun. Kullanici mesaji: ${prompt}`
};
}
function buildCoordinationPrompt(prompt) {
return {
mode: "coordination",
targetMember: null,
routedPrompt: `Yonlendirme notu: Bu mesaj kisa ekip ici koordinasyon gerektiriyor. Once Mazlum durumu acsin. Gerekirse ilgili ekip uyeleri kendi ad etiketiyle kisa konussun. Ekip ici hitap kurallarini uygula: Mazlum Bey, UI Hanim, erkek ekip uyeleri arasinda gerektiginde Frontend Kanka, Backend Kanka, iOS Kanka. Diyalog kisa olsun. Ardindan net sonuc veya karar acikca verilsin. Kullaniciya donecek cerceve saygili olsun ve Patron hitabi korunsun. Kullanici mesaji: ${prompt}`
};
}
function buildDirectPrompt(prompt, targetMember) {
if (targetMember.name === "Irgatov") {
return {
mode: "irgatov_direct",
targetMember,
routedPrompt: `Yonlendirme notu: Bu mesaj Irgatov icindir. Yalnizca Irgatov cevap versin. Cevap Irgatov: ile baslasin. Kullaniciya Patron diye hitap et. Irgatov sadece kahve, icecek, servis ve basit ofis lojistigi konularinda cevap verir. Teknik plan, kod, mimari, dosya yapisi veya teknoloji secimi hakkinda gorus bildirmez. Kullanici mesaji: ${prompt}`
};
}
return {
mode: "direct",
targetMember,
routedPrompt: `Yonlendirme notu: Bu mesaj dogrudan ${targetMember.name} icindir. Yalnizca ${targetMember.name} cevap versin. Cevap mutlaka ${targetMember.name}: ile baslasin. Kullaniciya mutlaka Patron diye hitap et. Kullaniciya karsi saygili, net ve profesyonel dil kullan. Gereksiz ekip ici diyalog kurma. Gerekirse cok kisa ofis tonu kullanabilirsin ama teknik icerigi golgeleme. Kullanici mesaji: ${prompt}`
};
}
function buildFollowUpPrompt(prompt, lastDirectedMember = null) {
if (!lastDirectedMember) {
return buildGeneralPrompt(prompt);
}
if (lastDirectedMember.name === "Irgatov") {
return buildDirectPrompt(prompt, lastDirectedMember);
}
return {
mode: "follow_up",
targetMember: lastDirectedMember,
routedPrompt: `Yonlendirme notu: Bu mesaj onceki konusmanin devamidir. Mumkunse onceki hedef kisi cevap versin. Cevap mevcut baglami korusun. Kullaniciya mutlaka Patron diye hitap et. Yalnizca gerekliyse kisa cevap ver. Gereksiz yeni ekip diyalogu baslatma. Cevap ${lastDirectedMember.name}: ile baslasin. Kullanici mesaji: ${prompt}`
};
}
function buildRoutedPrompt(prompt, lastDirectedMember = null) {
const mentionedMembers = findMentionedMembers(prompt);
if (isBriefingRequest(prompt)) {
return buildBriefingPrompt(prompt);
}
if (isCoordinationRequest(prompt)) {
return buildCoordinationPrompt(prompt);
}
if (mentionedMembers.length === 1) {
return buildDirectPrompt(prompt, mentionedMembers[0]);
}
if (mentionedMembers.length > 1) {
return buildGeneralPrompt(prompt);
}
if (lastDirectedMember && isLikelyFollowUp(prompt)) {
return buildFollowUpPrompt(prompt, lastDirectedMember);
}
return buildGeneralPrompt(prompt);
}
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.currentProjectPath = null;
this.state = {
status: "idle",
startedAt: null,
teamActivated: false,
lastError: null,
currentProjectPath: null,
runtime: getPublicRuntimeConfig(config)
};
}
getState() {
return {
...this.state,
currentProjectPath: this.currentProjectPath,
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();
}
getActiveWorkspaceDir() {
return this.currentProjectPath ?? this.config.workspaceDir;
}
async setProjectPath(projectPath) {
const resolved = projectPath ? path.resolve(projectPath) : null;
const wasRunning = this.ptyService?.isRunning() ?? false;
if (resolved && (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory())) {
throw new Error(`Selected project path is invalid: ${resolved}`);
}
if (wasRunning) {
await this.stop();
}
this.currentProjectPath = resolved;
this.lastDirectedMember = null;
this.setState({
currentProjectPath: resolved,
teamActivated: false
});
this.addLog("system", `Current project set to ${resolved ?? "None"}`);
if (wasRunning) {
await this.start();
if (resolved) {
await this.activateTeam();
}
}
}
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.getActiveWorkspaceDir(),
env: getClaudeEnv(this.config)
});
this.setState({
status: "starting",
startedAt: new Date().toISOString(),
teamActivated: false,
lastError: null
});
this.addLog("lifecycle", `Starting Claude session in ${this.getActiveWorkspaceDir()}`);
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.currentProjectPath);
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;
}
}

66
server/socketHandlers.js Normal file
View File

@@ -0,0 +1,66 @@
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 });
});
socket.on("project:select", async ({ projectPath }, callback) => {
try {
await sessionManager.setProjectPath(projectPath);
callback?.({ ok: true, projectPath: sessionManager.getState().currentProjectPath });
} catch (error) {
socket.emit("session:error", { message: error.message });
callback?.({ ok: false, error: error.message });
}
});
});
}

34
server/teamConfig.js Normal file
View File

@@ -0,0 +1,34 @@
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", "ui"] },
{ id: "ive", name: "Ive", aliases: ["ive", "ios developer", "ios", "ioscu", "ios developer"] },
{ id: "irgatov", name: "Irgatov", aliases: ["irgatov", "trainee", "intern", "stajyer"] }
];
function normalizeText(value) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
export function findMentionedMembers(prompt) {
const normalizedPrompt = normalizeText(prompt);
const matches = [];
for (const member of TEAM_MEMBERS) {
if (member.aliases.some((alias) => normalizedPrompt.includes(normalizeText(alias)))) {
matches.push(member);
}
}
return matches;
}
export function findMentionedMember(prompt) {
return findMentionedMembers(prompt)[0] ?? null;
}
export { TEAM_MEMBERS };

13
web/index.html Normal file
View 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>

BIN
web/public/apple-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web/public/ata-cropped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
web/public/mona.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

BIN
web/public/steve.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

224
web/src/App.jsx Normal file
View File

@@ -0,0 +1,224 @@
import { useEffect, useRef, useState } from "react";
import ShellFrame from "./components/ShellFrame.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 ToastStack from "./components/ToastStack.jsx";
import { useSocket } from "./hooks/useSocket.js";
import { useSession } from "./hooks/useSession.js";
import { createInitialOfficeAgents } from "./office/officeAgents.js";
import { parseOfficeCommand } from "./office/officeCommands.js";
import { getZoneById } from "./office/officeZones.js";
export default function App() {
const { socket, connected } = useSocket();
const { session, chat, startSession, clearProject, sendPrompt, selectProject, clearError, toasts, dismissToast } = useSession(socket);
const [busy, setBusy] = useState(false);
const [teamView, setTeamView] = useState("board");
const [officeAgents, setOfficeAgents] = useState(() => createInitialOfficeAgents());
const [selectedOfficeObject, setSelectedOfficeObject] = useState(null);
const [dismissedSpeech, setDismissedSpeech] = useState({});
const autoStartedRef = useRef(false);
async function runAction(action) {
setBusy(true);
clearError();
try {
await action();
} finally {
setBusy(false);
}
}
useEffect(() => {
if (!connected || autoStartedRef.current) {
return;
}
if (session.status === "idle") {
autoStartedRef.current = true;
runAction(startSession).catch(() => {
autoStartedRef.current = false;
});
}
}, [connected, session.status]);
function handleOfficeCommand(prompt) {
const command = parseOfficeCommand(prompt);
if (!command) {
return false;
}
const zone = getZoneById(command.zoneId);
const officeAgent = officeAgents[command.agentId];
if (!zone || !officeAgent) {
return false;
}
setOfficeAgents((current) => ({
...current,
[command.agentId]: {
...current[command.agentId],
targetZoneId: command.zoneId,
targetPosition: zone.approachPosition
}
}));
return true;
}
function handleAgentArrive(agentId, position) {
let arrivedZoneId = null;
setOfficeAgents((current) => {
arrivedZoneId = current[agentId]?.targetZoneId ?? null;
return {
...current,
[agentId]: {
...current[agentId],
currentPosition: position,
currentZoneId: arrivedZoneId
}
};
});
if (String(arrivedZoneId ?? "").endsWith("Desk")) {
setSelectedOfficeObject((current) =>
current?.type === "agent" && current.id === agentId ? null : current
);
}
}
function handleOfficeAgentSelect(agentId) {
setSelectedOfficeObject(() => ({ type: "agent", id: agentId }));
}
function handleOfficeObjectSelect(type, id) {
setSelectedOfficeObject(() => ({ type, id }));
}
function handleOfficeFloorSelect(position) {
if (!selectedOfficeObject || selectedOfficeObject.type !== "agent") {
return;
}
const agentId = selectedOfficeObject.id;
setOfficeAgents((current) => {
if (!current[agentId]) {
return current;
}
return {
...current,
[agentId]: {
...current[agentId],
targetZoneId: null,
targetPosition: [position[0], 0, position[2]]
}
};
});
}
function handleOfficeZoneSelect(zoneId) {
if (!selectedOfficeObject || selectedOfficeObject.type !== "agent") {
return;
}
const zone = getZoneById(zoneId);
if (!zone) {
return;
}
const agentId = selectedOfficeObject.id;
setOfficeAgents((current) => {
if (!current[agentId]) {
return current;
}
return {
...current,
[agentId]: {
...current[agentId],
targetZoneId: zoneId,
targetPosition: zone.approachPosition
}
};
});
}
function handleDismissSpeech(agentId, speechKey) {
setDismissedSpeech((current) => ({
...current,
[agentId]: speechKey
}));
}
async function handlePromptSubmit(prompt) {
handleOfficeCommand(prompt);
await runAction(() => sendPrompt(prompt));
}
return (
<main className="app-shell">
<div className="app-shell__header">
<div className="app-shell__title">
<p className="app-shell__eyebrow">1996 COMMAND CENTER</p>
<h1>Retro Claude Team Console</h1>
<p className="app-shell__project" title={session.currentProjectPath ?? "None"}>
<span>Current Project:</span> {session.currentProjectPath ?? "None"}
</p>
</div>
<div className="app-shell__meta">
<span>LINK: {connected ? "ONLINE" : "OFFLINE"}</span>
<span>SESSION: {String(session.status || "idle").toUpperCase()}</span>
<span>TEAM: {session.teamActivated ? "ACTIVE" : "STANDBY"}</span>
<span>MODEL: {session.runtime?.anthropicModel || "N/A"}</span>
<span>KEY: {String(session.runtime?.activeKey || "pro").toUpperCase()}</span>
</div>
</div>
<ShellFrame>
<div className="console-grid">
<div className="console-grid__side">
<TeamBoard
chat={chat}
view={teamView}
onViewChange={setTeamView}
officeAgents={officeAgents}
onAgentArrive={handleAgentArrive}
selectedOfficeObject={selectedOfficeObject}
onAgentSelect={handleOfficeAgentSelect}
onOfficeObjectSelect={handleOfficeObjectSelect}
onFloorSelect={handleOfficeFloorSelect}
onZoneSelect={handleOfficeZoneSelect}
dismissedSpeech={dismissedSpeech}
onDismissSpeech={handleDismissSpeech}
/>
</div>
<div className="console-grid__main">
<ChatStream
chat={chat}
session={session}
headerExtra={
<SessionToolbar
session={session}
busy={busy}
onClearProject={() => runAction(clearProject)}
onSelectProject={() => runAction(selectProject)}
/>
}
/>
<PromptComposer
disabled={busy || session.status !== "running" || !session.teamActivated || !session.currentProjectPath}
onSubmit={handlePromptSubmit}
/>
</div>
</div>
<ToastStack toasts={toasts} onDismiss={dismissToast} />
</ShellFrame>
</main>
);
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useRef } from "react";
import PanelFrame from "./PanelFrame.jsx";
export default function ChatStream({ chat, session, headerExtra = null }) {
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"
headerExtra={headerExtra}
>
<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>
);
}

View File

@@ -0,0 +1,14 @@
export default function PanelFrame({ title, eyebrow, children, className = "", headerExtra = null }) {
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>
{headerExtra ? <div className="panel-frame__extra">{headerExtra}</div> : null}
</div>
<div className="panel-frame__body">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,7 @@
export default function PixelButton({ tone = "green", disabled, className = "", children, ...props }) {
return (
<button className={`pixel-button pixel-button--${tone} ${className}`.trim()} disabled={disabled} {...props}>
<span>{children}</span>
</button>
);
}

View 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>
);
}

View File

@@ -0,0 +1,14 @@
import PixelButton from "./PixelButton.jsx";
export default function SessionToolbar({ session, busy, onClearProject, onSelectProject }) {
return (
<div className="session-toolbar session-toolbar--inline">
<PixelButton tone="red" disabled={busy || !session.currentProjectPath} onClick={onClearProject}>
Clean Project
</PixelButton>
<PixelButton tone="amber" disabled={busy} onClick={onSelectProject}>
Select Project
</PixelButton>
</div>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,124 @@
import { Suspense, lazy } from "react";
import PanelFrame from "./PanelFrame.jsx";
import PixelButton from "./PixelButton.jsx";
import { parseTeamFeed } from "../lib/teamFeed.js";
const OfficeCanvas = lazy(() => import("../office/OfficeCanvas.jsx"));
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).reverse().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,
view = "board",
onViewChange,
officeAgents,
onAgentArrive,
selectedOfficeObject,
onAgentSelect,
onOfficeObjectSelect,
onFloorSelect,
onZoneSelect,
dismissedSpeech,
onDismissSpeech
}) {
const members = parseTeamFeed(chat);
const isOfficeView = view === "office";
const speechByAgent = {
teamLead: members.find((member) => member.name === "Mazlum")?.messages.at(-1)?.text ?? "",
frontend: members.find((member) => member.name === "Berkecan")?.messages.at(-1)?.text ?? "",
backend: members.find((member) => member.name === "Simsar")?.messages.at(-1)?.text ?? "",
uiux: members.find((member) => member.name === "Aybuke")?.messages.at(-1)?.text ?? "",
ios: members.find((member) => member.name === "Ive")?.messages.at(-1)?.text ?? "",
trainee: members.find((member) => member.name === "Irgatov")?.messages.at(-1)?.text ?? ""
};
const speechEntries = Object.fromEntries(
Object.entries(speechByAgent).map(([agentId, text]) => [agentId, { text, key: `${agentId}:${text}` }])
);
return (
<PanelFrame
title="Team Comms Board"
eyebrow="ROLE SIGNALS"
className={`team-board-panel team-board-panel--${view}`}
headerExtra={
<div className="team-board__tabs" role="tablist" aria-label="Team Comms Board view">
<PixelButton
tone={view === "board" ? "green" : "cyan"}
className={`team-board__tab ${view === "board" ? "is-active" : ""}`}
onClick={() => onViewChange?.("board")}
aria-pressed={view === "board"}
>
Board
</PixelButton>
<span className="team-board__divider" aria-hidden="true">
|
</span>
<PixelButton
tone={view === "office" ? "red" : "cyan"}
className={`team-board__tab ${view === "office" ? "is-active" : ""}`}
onClick={() => onViewChange?.("office")}
aria-pressed={view === "office"}
>
Office
</PixelButton>
</div>
}
>
{isOfficeView ? (
<div className="team-board__office" aria-label="Office view">
<Suspense fallback={<div className="team-board__office-fallback" />}>
<OfficeCanvas
debug={false}
agents={officeAgents}
onAgentArrive={onAgentArrive}
speechByAgent={speechEntries}
selectedOfficeObject={selectedOfficeObject}
onAgentSelect={onAgentSelect}
onOfficeObjectSelect={onOfficeObjectSelect}
onFloorSelect={onFloorSelect}
onZoneSelect={onZoneSelect}
dismissedSpeech={dismissedSpeech}
onDismissSpeech={onDismissSpeech}
/>
</Suspense>
</div>
) : (
<div className="team-board">
{members.map((member) => (
<TeamCard key={member.id} member={member} />
))}
</div>
)}
</PanelFrame>
);
}

View File

@@ -0,0 +1,22 @@
export default function ToastStack({ toasts = [], onDismiss }) {
if (!toasts.length) {
return null;
}
return (
<div className="toast-stack" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<button
key={toast.id}
type="button"
className={`toast toast--${toast.tone || "error"}`}
onClick={() => onDismiss?.(toast.id)}
title="Dismiss notification"
>
<span className="toast__label">{toast.tone === "error" ? "ALERT" : "NOTICE"}</span>
<span className="toast__message">{toast.message}</span>
</button>
))}
</div>
);
}

View 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
View 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
};
}

130
web/src/hooks/useSession.js Normal file
View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
const initialState = {
status: "idle",
startedAt: null,
teamActivated: false,
lastError: null,
currentProjectPath: null,
runtime: {
anthropicModel: "",
anthropicBaseUrl: "",
activeKey: "pro"
}
};
export function useSession(socket) {
const [session, setSession] = useState(initialState);
const [chat, setChat] = useState("");
const [error, setError] = useState("");
const [toasts, setToasts] = useState([]);
function pushToast(message, tone = "error") {
if (!message) {
return;
}
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, message, tone }].slice(-4));
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 4200);
}
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);
pushToast(message, "error");
};
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);
pushToast(message, "error");
reject(new Error(message));
return;
}
setError("");
resolve(response);
});
});
}
return {
session,
chat,
error,
toasts,
clearError: () => setError(""),
dismissToast: (id) => setToasts((current) => current.filter((toast) => toast.id !== id)),
startSession: () => emitWithAck("session:start"),
stopSession: () => emitWithAck("session:stop"),
activateTeam: () => emitWithAck("team:activate"),
sendPrompt: (prompt) => emitWithAck("prompt:send", { prompt }),
selectProject: async () => {
const response = await fetch("/api/project/select", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({})
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
const message = payload.error ?? "Project selection failed";
setError(message);
pushToast(message, "error");
throw new Error(message);
}
setError("");
return payload;
},
clearProject: async () => {
const response = await fetch("/api/project/clear", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({})
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
const message = payload.error ?? "Project cleanup failed";
setError(message);
pushToast(message, "error");
throw new Error(message);
}
setError("");
return payload;
},
resizeTerminal: (cols, rows) => socket.emit("terminal:resize", { cols, rows })
};
}

View 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
};
}

View 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";
}
}

179
web/src/lib/teamFeed.js Normal file
View File

@@ -0,0 +1,179 @@
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)
].some(Boolean);
}
function isContinuationLine(line) {
const trimmed = line.trim();
if (!trimmed) {
return true;
}
return [
/^[•\-]\s+/.test(trimmed),
/^[0-9]+\.\s+/.test(trimmed),
/^[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 normalizedText = String(message.text ?? "").replace(/\s+/g, " ").trim();
const firstLine = normalizedText.split("\n")[0]?.trim() ?? "";
const key = `${message.speaker}::${normalizedText}`;
if (seen.has(key)) {
continue;
}
const lastMessage = result[result.length - 1];
if (lastMessage) {
const lastNormalized = String(lastMessage.text ?? "").replace(/\s+/g, " ").trim();
const lastFirstLine = lastNormalized.split("\n")[0]?.trim() ?? "";
if (
lastMessage.speaker === message.speaker &&
firstLine &&
lastFirstLine === firstLine
) {
const mergedText = lastNormalized.length >= normalizedText.length ? lastMessage.text : message.text;
result[result.length - 1] = {
...lastMessage,
text: mergedText
};
seen.add(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) {
if (currentEntry && isContinuationLine(line)) {
currentEntry.text = currentEntry.text ? `${currentEntry.text}\n${line}` : line;
}
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
View 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>
);

View File

@@ -0,0 +1,376 @@
import { useEffect, useRef } from "react";
import { Billboard, Html, RoundedBox } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { Vector3 } from "three";
function nearlyEqual(a, b, epsilon = 0.04) {
return Math.abs(a - b) < epsilon;
}
function positionsMatch(current, target) {
return nearlyEqual(current[0], target[0]) && nearlyEqual(current[1], target[1]) && nearlyEqual(current[2], target[2]);
}
function HairStyle({ style, color }) {
if (style === "bob") {
return (
<group>
<mesh castShadow position={[0, 1.77, 0.01]}>
<boxGeometry args={[0.46, 0.16, 0.44]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
<mesh castShadow position={[-0.19, 1.63, -0.01]}>
<boxGeometry args={[0.09, 0.24, 0.36]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
<mesh castShadow position={[0.19, 1.63, -0.01]}>
<boxGeometry args={[0.09, 0.24, 0.36]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
</group>
);
}
if (style === "swept") {
return (
<group>
<mesh castShadow position={[0, 1.76, 0.02]}>
<boxGeometry args={[0.4, 0.12, 0.42]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
<mesh castShadow position={[0.09, 1.82, 0.06]} rotation={[0, 0, -0.38]}>
<boxGeometry args={[0.22, 0.06, 0.32]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
</group>
);
}
if (style === "sidePart") {
return (
<group>
<mesh castShadow position={[0, 1.76, 0.02]}>
<boxGeometry args={[0.42, 0.12, 0.42]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
<mesh castShadow position={[-0.08, 1.8, 0.06]} rotation={[0, 0, 0.22]}>
<boxGeometry args={[0.18, 0.05, 0.28]} />
<meshStandardMaterial color={color} roughness={0.95} />
</mesh>
</group>
);
}
if (style === "crew") {
return (
<mesh castShadow position={[0, 1.75, 0.03]}>
<boxGeometry args={[0.36, 0.08, 0.36]} />
<meshStandardMaterial color={color} roughness={0.98} />
</mesh>
);
}
return (
<mesh castShadow position={[0, 1.76, 0.04]}>
<boxGeometry args={[0.4, 0.12, 0.42]} />
<meshStandardMaterial color={color} roughness={0.96} />
</mesh>
);
}
export default function OfficeAgent({
agent,
onArrive,
selected = false,
speechText = "",
speechKey = "",
onSelect,
onDismissSpeech
}) {
const groupRef = useRef(null);
const bodyRef = useRef(null);
const lastArrivalKeyRef = useRef("");
const targetRef = useRef(new Vector3(...agent.targetPosition));
const leftArmRef = useRef(null);
const rightArmRef = useRef(null);
const leftLegRef = useRef(null);
const rightLegRef = useRef(null);
const walkCycleRef = useRef(0);
const appearance = {
skinColor: "#f0c6a9",
hairColor: "#171717",
hairStyle: "short",
shirtColor: "#fbfbf4",
tieColor: null,
lowerColor: "#6d7078",
shoeColor: "#111111",
lowerType: "pants",
...agent.appearance
};
const isSkirt = appearance.lowerType === "skirt";
const isMoving = !positionsMatch(agent.currentPosition, agent.targetPosition);
const isSeated = !isMoving && String(agent.currentZoneId ?? "").endsWith("Desk");
useEffect(() => {
if (!groupRef.current) {
return;
}
groupRef.current.position.set(...agent.currentPosition);
}, [agent.currentPosition]);
useEffect(() => {
targetRef.current.set(...agent.targetPosition);
}, [agent.targetPosition]);
useFrame((_, delta) => {
if (!groupRef.current) {
return;
}
const speed = Math.min(1, delta * 1.44);
groupRef.current.position.lerp(targetRef.current, speed);
const directionX = targetRef.current.x - groupRef.current.position.x;
const directionZ = targetRef.current.z - groupRef.current.position.z;
if (bodyRef.current) {
if (isMoving && (Math.abs(directionX) > 0.01 || Math.abs(directionZ) > 0.01)) {
const targetYaw = Math.atan2(directionX, directionZ);
bodyRef.current.rotation.y += (targetYaw - bodyRef.current.rotation.y) * Math.min(1, delta * 8);
} else if (isSeated) {
bodyRef.current.rotation.y += (0 - bodyRef.current.rotation.y) * Math.min(1, delta * 8);
}
}
if (isMoving) {
walkCycleRef.current += delta * 6;
const swing = Math.sin(walkCycleRef.current) * 0.45;
if (leftArmRef.current) {
leftArmRef.current.rotation.x = swing;
}
if (rightArmRef.current) {
rightArmRef.current.rotation.x = -swing;
}
if (leftLegRef.current) {
leftLegRef.current.rotation.x = -swing;
}
if (rightLegRef.current) {
rightLegRef.current.rotation.x = swing;
}
} else if (isSeated) {
if (leftArmRef.current) {
leftArmRef.current.rotation.x = -0.18;
}
if (rightArmRef.current) {
rightArmRef.current.rotation.x = -0.18;
}
if (leftLegRef.current) {
leftLegRef.current.rotation.x = Math.PI / 2.7;
}
if (rightLegRef.current) {
rightLegRef.current.rotation.x = Math.PI / 2.7;
}
} else {
if (leftArmRef.current) {
leftArmRef.current.rotation.x = 0;
}
if (rightArmRef.current) {
rightArmRef.current.rotation.x = 0;
}
if (leftLegRef.current) {
leftLegRef.current.rotation.x = 0;
}
if (rightLegRef.current) {
rightLegRef.current.rotation.x = 0;
}
}
const current = [
groupRef.current.position.x,
groupRef.current.position.y,
groupRef.current.position.z
];
const arrivalKey = `${agent.id}:${agent.targetPosition.join(",")}`;
if (positionsMatch(current, agent.targetPosition) && lastArrivalKeyRef.current !== arrivalKey) {
lastArrivalKeyRef.current = arrivalKey;
onArrive?.(agent.id, agent.targetPosition);
}
});
return (
<group
ref={groupRef}
onPointerDown={(event) => {
event.stopPropagation();
onSelect?.(agent.id);
}}
>
{selected ? (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
<ringGeometry args={[0.36, 0.48, 32]} />
<meshBasicMaterial color="#7ff7ff" transparent opacity={0.8} />
</mesh>
) : null}
<group ref={bodyRef}>
<HairStyle style={appearance.hairStyle} color={appearance.hairColor} />
<mesh castShadow position={[0, isSeated ? 1.47 : 1.55, 0]}>
<boxGeometry args={[0.42, 0.42, 0.42]} />
<meshStandardMaterial color={appearance.skinColor} roughness={0.95} />
</mesh>
<mesh castShadow position={[-0.08, isSeated ? 1.5 : 1.58, 0.222]}>
<boxGeometry args={[0.08, 0.012, 0.016]} />
<meshStandardMaterial color="#101010" roughness={0.4} />
</mesh>
<mesh castShadow position={[0.08, isSeated ? 1.5 : 1.58, 0.222]}>
<boxGeometry args={[0.08, 0.012, 0.016]} />
<meshStandardMaterial color="#101010" roughness={0.4} />
</mesh>
<mesh castShadow position={[-0.08, isSeated ? 1.57 : 1.65, 0.214]}>
<boxGeometry args={[0.1, 0.018, 0.02]} />
<meshStandardMaterial color="#222222" roughness={0.7} />
</mesh>
<mesh castShadow position={[0.08, isSeated ? 1.57 : 1.65, 0.214]}>
<boxGeometry args={[0.1, 0.018, 0.02]} />
<meshStandardMaterial color="#222222" roughness={0.7} />
</mesh>
<mesh castShadow position={[0, isSeated ? 1.42 : 1.5, 0.22]} rotation={[Math.PI / 2.4, 0, 0]}>
<coneGeometry args={[0.035, 0.13, 4]} />
<meshStandardMaterial color="#deaf90" roughness={0.88} />
</mesh>
<mesh castShadow position={[0, isSeated ? 1.31 : 1.39, 0.222]}>
<boxGeometry args={[0.12, 0.012, 0.016]} />
<meshStandardMaterial color="#101010" roughness={0.5} />
</mesh>
<RoundedBox castShadow receiveShadow position={[0, isSeated ? 1.02 : 1.13, 0]} args={[0.62, 0.58, 0.32]} radius={0.04} smoothness={2}>
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
</RoundedBox>
{appearance.tieColor ? (
<mesh castShadow position={[0, isSeated ? 1.01 : 1.12, 0.17]}>
<boxGeometry args={[0.11, 0.48, 0.03]} />
<meshStandardMaterial color={appearance.tieColor} roughness={0.64} />
</mesh>
) : null}
<group ref={leftArmRef} position={[-0.44, isSeated ? 1.12 : 1.24, 0]}>
<mesh castShadow position={[0, -0.09, 0]}>
<boxGeometry args={[0.22, 0.16, 0.18]} />
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
</mesh>
<mesh castShadow position={[0, -0.31, 0]}>
<boxGeometry args={[0.14, 0.28, 0.14]} />
<meshStandardMaterial color={appearance.skinColor} roughness={0.92} />
</mesh>
</group>
<group ref={rightArmRef} position={[0.44, isSeated ? 1.12 : 1.24, 0]}>
<mesh castShadow position={[0, -0.09, 0]}>
<boxGeometry args={[0.22, 0.16, 0.18]} />
<meshStandardMaterial color={appearance.shirtColor} roughness={0.82} />
</mesh>
<mesh castShadow position={[0, -0.31, 0]}>
<boxGeometry args={[0.14, 0.28, 0.14]} />
<meshStandardMaterial color={appearance.skinColor} roughness={0.92} />
</mesh>
</group>
{isSkirt ? (
<mesh castShadow position={[0, isSeated ? 0.7 : 0.76, 0]}>
<boxGeometry args={[0.54, 0.22, 0.34]} />
<meshStandardMaterial color={appearance.lowerColor} roughness={0.86} />
</mesh>
) : (
<mesh castShadow position={[0, isSeated ? 0.72 : 0.76, 0]}>
<boxGeometry args={[0.5, 0.22, 0.3]} />
<meshStandardMaterial color={appearance.lowerColor} roughness={0.86} />
</mesh>
)}
{isSeated ? (
<>
<group ref={leftLegRef} position={[-0.14, 0.73, 0.03]}>
<mesh castShadow position={[0, -0.07, 0.11]}>
<boxGeometry args={[0.14, 0.14, 0.34]} />
<meshStandardMaterial color={appearance.lowerColor} roughness={0.88} />
</mesh>
<mesh castShadow position={[0, -0.39, 0.24]}>
<boxGeometry args={[0.14, 0.42, 0.14]} />
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, -0.65, 0.32]}>
<boxGeometry args={[0.16, 0.12, 0.24]} />
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
</mesh>
</group>
<group ref={rightLegRef} position={[0.14, 0.73, 0.03]}>
<mesh castShadow position={[0, -0.07, 0.11]}>
<boxGeometry args={[0.14, 0.14, 0.34]} />
<meshStandardMaterial color={appearance.lowerColor} roughness={0.88} />
</mesh>
<mesh castShadow position={[0, -0.39, 0.24]}>
<boxGeometry args={[0.14, 0.42, 0.14]} />
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, -0.65, 0.32]}>
<boxGeometry args={[0.16, 0.12, 0.24]} />
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
</mesh>
</group>
</>
) : (
<>
<group ref={leftLegRef} position={[-0.14, 0.66, 0]}>
<mesh castShadow position={[0, -0.25, 0]}>
<boxGeometry args={[0.14, 0.48, 0.14]} />
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, -0.04, 0]}>
<boxGeometry args={[0.16, 0.08, 0.16]} />
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, -0.55, 0.02]}>
<boxGeometry args={[0.16, 0.12, 0.24]} />
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
</mesh>
</group>
<group ref={rightLegRef} position={[0.14, 0.66, 0]}>
<mesh castShadow position={[0, -0.25, 0]}>
<boxGeometry args={[0.14, 0.48, 0.14]} />
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, -0.04, 0]}>
<boxGeometry args={[0.16, 0.08, 0.16]} />
<meshStandardMaterial color={isSkirt ? appearance.skinColor : appearance.lowerColor} roughness={0.9} />
</mesh>
<mesh castShadow position={[0, -0.55, 0.02]}>
<boxGeometry args={[0.16, 0.12, 0.24]} />
<meshStandardMaterial color={appearance.shoeColor} roughness={0.9} />
</mesh>
</group>
</>
)}
<Html position={[0, 2.02, 0]} center distanceFactor={10}>
<div className="office-agent__label">{agent.name}</div>
</Html>
</group>
{speechText ? (
<Billboard position={[0, 3.03, 0]} follow lockX={false} lockY={false} lockZ={false}>
<Html center distanceFactor={10}>
<button
type="button"
className="office-agent__bubble"
onWheel={(event) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.scrollTop += event.deltaY;
}}
onClick={(event) => {
event.stopPropagation();
onDismissSpeech?.(agent.id, speechKey);
}}
>
{speechText}
</button>
</Html>
</Billboard>
) : null}
</group>
);
}

View File

@@ -0,0 +1,16 @@
import { OrbitControls } from "@react-three/drei";
export default function OfficeCamera() {
return (
<OrbitControls
enablePan={false}
enableDamping
dampingFactor={0.08}
minDistance={12}
maxDistance={30}
minPolarAngle={0.7}
maxPolarAngle={1.02}
target={[0.5, 0.8, 0.2]}
/>
);
}

View File

@@ -0,0 +1,36 @@
import { Canvas } from "@react-three/fiber";
import OfficeScene from "./OfficeScene.jsx";
export default function OfficeCanvas({
debug = false,
agents = {},
onAgentArrive,
speechByAgent = {},
selectedOfficeObject,
onAgentSelect,
onOfficeObjectSelect,
onFloorSelect,
onZoneSelect,
dismissedSpeech,
onDismissSpeech
}) {
return (
<div className="office-canvas">
<Canvas shadows dpr={[1, 1.5]} camera={{ position: [12, 12, 12], fov: 38 }}>
<OfficeScene
debug={debug}
agents={agents}
onAgentArrive={onAgentArrive}
speechByAgent={speechByAgent}
selectedOfficeObject={selectedOfficeObject}
onAgentSelect={onAgentSelect}
onOfficeObjectSelect={onOfficeObjectSelect}
onFloorSelect={onFloorSelect}
onZoneSelect={onZoneSelect}
dismissedSpeech={dismissedSpeech}
onDismissSpeech={onDismissSpeech}
/>
</Canvas>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { Html } from "@react-three/drei";
export default function OfficeDebugLabels({ zones = [] }) {
return (
<group>
{zones.map((zone) => (
<Html key={zone.id} position={[zone.position[0], 1.8, zone.position[2]]} center transform sprite>
<div className="office-debug-label">
<span>{zone.label}</span>
<code>{zone.id}</code>
</div>
</Html>
))}
</group>
);
}

View File

@@ -0,0 +1,16 @@
export default function OfficeFloor({ onSelect }) {
return (
<mesh
receiveShadow
rotation={[-Math.PI / 2, 0, 0]}
position={[0, -0.001, 0]}
onPointerDown={(event) => {
event.stopPropagation();
onSelect?.(event.point.toArray());
}}
>
<planeGeometry args={[24, 18]} />
<meshStandardMaterial color="#efe0c5" roughness={0.95} metalness={0.02} />
</mesh>
);
}

View File

@@ -0,0 +1,86 @@
import { useLoader } from "@react-three/fiber";
import { TextureLoader } from "three";
import Desk from "./primitives/Desk.jsx";
import MeetingTable from "./primitives/MeetingTable.jsx";
import CoffeeMachine from "./primitives/CoffeeMachine.jsx";
import { OFFICE_ZONES } from "./officeZones.js";
function AccentWallPanels() {
const steveTexture = useLoader(TextureLoader, "/steve.png");
const monaTexture = useLoader(TextureLoader, "/mona.png");
const ataTexture = useLoader(TextureLoader, "/ata-cropped.png");
const panels = [
{ position: [-8.2, 1.8, -8.78], color: "#495b8a" },
{ position: [0, 2.15, -8.76], color: "#6b4c78" },
{ position: [7.4, 1.8, -8.78], color: "#495b8a" }
];
return (
<group>
<mesh position={[-6.2, 1.47, -8.73]}>
<planeGeometry args={[1.456, 2.184]} />
<meshBasicMaterial
map={monaTexture}
transparent
alphaTest={0.05}
toneMapped={false}
/>
</mesh>
<mesh position={[0, 1.47, -8.73]}>
<planeGeometry args={[3.24, 2.184]} />
<meshBasicMaterial
map={steveTexture}
transparent
alphaTest={0.05}
toneMapped={false}
/>
</mesh>
<mesh position={[6.2, 1.47, -8.73]}>
<planeGeometry args={[1.62, 2.184]} />
<meshBasicMaterial
map={ataTexture}
transparent
alphaTest={0.05}
toneMapped={false}
/>
</mesh>
</group>
);
}
function FloorDecals() {
return (
<group>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[5.8, 0.002, 4]}>
<circleGeometry args={[1.15, 24]} />
<meshStandardMaterial color="#d9ccb6" roughness={1} />
</mesh>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[6.4, 0.002, -4]}>
<circleGeometry args={[2.15, 28]} />
<meshStandardMaterial color="#ded0ba" roughness={1} />
</mesh>
</group>
);
}
export default function OfficeLayout({ selectedOfficeObject, onOfficeObjectSelect, onZoneSelect }) {
const coffeeMachineSelected = selectedOfficeObject?.type === "object" && selectedOfficeObject?.id === "coffeeMachine";
return (
<group>
<FloorDecals />
<AccentWallPanels />
<Desk position={OFFICE_ZONES[0].position} nameplateColor="#d6c15d" onSelect={() => onZoneSelect?.("teamLeadDesk")} />
<Desk position={OFFICE_ZONES[1].position} nameplateColor="#49c2f1" onSelect={() => onZoneSelect?.("frontendDesk")} />
<Desk position={OFFICE_ZONES[2].position} nameplateColor="#7bd87a" onSelect={() => onZoneSelect?.("backendDesk")} />
<Desk position={OFFICE_ZONES[3].position} nameplateColor="#f48cc7" onSelect={() => onZoneSelect?.("uiuxDesk")} />
<Desk position={OFFICE_ZONES[4].position} nameplateColor="#8aa4ff" onSelect={() => onZoneSelect?.("iosDesk")} />
<MeetingTable position={OFFICE_ZONES[5].position} />
<CoffeeMachine
position={OFFICE_ZONES[6].position}
selected={coffeeMachineSelected}
onSelect={() => onOfficeObjectSelect?.("object", "coffeeMachine")}
/>
</group>
);
}

View File

@@ -0,0 +1,21 @@
export default function OfficeLighting() {
return (
<>
<ambientLight intensity={2.1} />
<hemisphereLight args={["#fff8ea", "#2a2018", 1.45]} />
<directionalLight
castShadow
intensity={2.45}
position={[7, 12, 5]}
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
shadow-camera-near={0.5}
shadow-camera-far={40}
shadow-camera-left={-16}
shadow-camera-right={16}
shadow-camera-top={16}
shadow-camera-bottom={-16}
/>
</>
);
}

View File

@@ -0,0 +1,51 @@
import OfficeCamera from "./OfficeCamera.jsx";
import OfficeLighting from "./OfficeLighting.jsx";
import OfficeFloor from "./OfficeFloor.jsx";
import OfficeWalls from "./OfficeWalls.jsx";
import OfficeLayout from "./OfficeLayout.jsx";
import OfficeDebugLabels from "./OfficeDebugLabels.jsx";
import OfficeAgent from "./OfficeAgent.jsx";
import { OFFICE_ZONES } from "./officeZones.js";
export default function OfficeScene({
debug = false,
agents = {},
onAgentArrive,
speechByAgent = {},
selectedOfficeObject,
onAgentSelect,
onOfficeObjectSelect,
onFloorSelect,
onZoneSelect,
dismissedSpeech = {},
onDismissSpeech
}) {
return (
<>
<color attach="background" args={["#1a130f"]} />
<fog attach="fog" args={["#1a130f", 42, 72]} />
<OfficeLighting />
<OfficeFloor onSelect={onFloorSelect} />
<OfficeWalls />
<OfficeLayout
selectedOfficeObject={selectedOfficeObject}
onOfficeObjectSelect={onOfficeObjectSelect}
onZoneSelect={onZoneSelect}
/>
{Object.values(agents).map((agent) => (
<OfficeAgent
key={agent.id}
agent={agent}
onArrive={onAgentArrive}
selected={selectedOfficeObject?.type === "agent" && selectedOfficeObject?.id === agent.id}
speechText={dismissedSpeech[agent.id] === speechByAgent[agent.id]?.key ? "" : (speechByAgent[agent.id]?.text ?? "")}
speechKey={speechByAgent[agent.id]?.key ?? ""}
onSelect={onAgentSelect}
onDismissSpeech={onDismissSpeech}
/>
))}
<OfficeCamera />
{debug ? <OfficeDebugLabels zones={OFFICE_ZONES} /> : null}
</>
);
}

View File

@@ -0,0 +1,19 @@
function Wall({ position, args }) {
return (
<mesh castShadow receiveShadow position={position}>
<boxGeometry args={args} />
<meshStandardMaterial color="#8f8b93" roughness={0.92} metalness={0.04} />
</mesh>
);
}
export default function OfficeWalls() {
return (
<group>
<Wall position={[0, 1.3, -9]} args={[24, 2.6, 0.35]} />
<Wall position={[-12, 1.3, 0]} args={[0.35, 2.6, 18]} />
<Wall position={[12, 1.3, 0]} args={[0.35, 2.6, 18]} />
<Wall position={[0, 1.3, 9]} args={[24, 2.6, 0.35]} />
</group>
);
}

View File

@@ -0,0 +1,114 @@
import { getZoneById } from "./officeZones.js";
function createAgent({
id,
name,
role,
zoneId,
color,
appearance
}) {
const zone = getZoneById(zoneId);
return {
id,
name,
role,
color,
currentZoneId: zoneId,
targetZoneId: zoneId,
currentPosition: zone?.approachPosition ?? [0, 0, 0],
targetPosition: zone?.approachPosition ?? [0, 0, 0],
appearance
};
}
export function createInitialOfficeAgents() {
return {
teamLead: createAgent({
id: "teamLead",
name: "Mazlum",
role: "Team Lead",
zoneId: "teamLeadDesk",
color: "#e0c15c",
appearance: {
skinColor: "#f0c6a9",
hairColor: "#171717",
hairStyle: "short",
shirtColor: "#fbfbf4",
tieColor: "#ba1d2f",
lowerColor: "#75777d",
shoeColor: "#111111",
lowerType: "pants"
}
}),
frontend: createAgent({
id: "frontend",
name: "Berkecan",
role: "Frontend Developer",
zoneId: "frontendDesk",
color: "#49c2f1",
appearance: {
skinColor: "#e8c09b",
hairColor: "#3e2415",
hairStyle: "swept",
shirtColor: "#3d79d8",
tieColor: null,
lowerColor: "#d7c8a4",
shoeColor: "#3b2d25",
lowerType: "pants"
}
}),
backend: createAgent({
id: "backend",
name: "Simsar",
role: "Backend Developer",
zoneId: "backendDesk",
color: "#7bd87a",
appearance: {
skinColor: "#dcb28f",
hairColor: "#2a2a2a",
hairStyle: "crew",
shirtColor: "#2f6b48",
tieColor: null,
lowerColor: "#42464f",
shoeColor: "#141414",
lowerType: "pants"
}
}),
uiux: createAgent({
id: "uiux",
name: "Aybuke",
role: "UI/UX Designer",
zoneId: "uiuxDesk",
color: "#f48cc7",
appearance: {
skinColor: "#efc4aa",
hairColor: "#6b3f30",
hairStyle: "bob",
shirtColor: "#d8a0cf",
tieColor: null,
lowerColor: "#564760",
shoeColor: "#2f2430",
lowerType: "skirt"
}
}),
ios: createAgent({
id: "ios",
name: "Ive",
role: "iOS Developer",
zoneId: "iosDesk",
color: "#8aa4ff",
appearance: {
skinColor: "#e6bc97",
hairColor: "#7b4d22",
hairStyle: "sidePart",
shirtColor: "#d4b258",
tieColor: null,
lowerColor: "#5a77ae",
shoeColor: "#171717",
lowerType: "pants"
}
})
};
}

View File

@@ -0,0 +1,62 @@
const AGENT_ALIASES = {
teamLead: ["team lead", "lead", "mazlum", "mazlum bey"],
frontend: ["frontend", "frontend developer", "berkecan"],
backend: ["backend", "backend developer", "simsar"],
uiux: ["ui ux", "ui/ux", "designer", "ui ux designer", "aybuke", "aybuke hanim", "aybüke", "aybüke hanım"],
ios: ["ios", "ios developer", "ive", "i os"]
};
const ZONE_ALIASES = {
teamLeadDesk: ["team lead desk", "lead desk", "mazlum desk", "team lead masasi", "mazlum masasi"],
frontendDesk: ["frontend desk", "frontend masasi", "berkecan masasi"],
backendDesk: ["backend desk", "backend masasi", "simsar masasi"],
uiuxDesk: ["ui ux desk", "ui/ux desk", "designer desk", "aybuke masasi", "aybüke masası", "ui ux masasi"],
iosDesk: ["ios desk", "ios masasi", "ive masasi", "i os masasi"],
meetingTable: ["meeting table", "toplanti masasi", "toplanti masasi", "meeting room", "toplanti"],
coffeeMachine: ["coffee machine", "kahve makinesi", "kahve alani", "kahve makinasi"]
};
function normalizeText(value) {
return String(value ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function findMatch(normalizedText, map) {
return Object.entries(map).find(([, aliases]) => aliases.some((alias) => normalizedText.includes(alias)))?.[0] ?? null;
}
function inferZoneFromIntent(normalizedText) {
if (/\b(kahve al|kahve alsin|kahve alsın|kahve getir|kahve makinesi|kahve makinasi|kahve alanina|kahve alanına)\b/.test(normalizedText)) {
return "coffeeMachine";
}
if (/\b(toplantiya git|toplanti masasi|toplantiya|toplanti alanina|toplanti alanina|masaya gec|masaya geç)\b/.test(normalizedText)) {
return "meetingTable";
}
return null;
}
export function parseOfficeCommand(prompt) {
const normalized = normalizeText(prompt);
if (!/\b(git|gidebilir|gitsin|gidin|toplan|yuru|yurusun|gitmesi|al|alsin|alsın|getir)\b/.test(normalized)) {
return null;
}
const agentId = findMatch(normalized, AGENT_ALIASES);
const zoneId = findMatch(normalized, ZONE_ALIASES) ?? inferZoneFromIntent(normalized);
if (!agentId || !zoneId) {
return null;
}
return {
type: "move",
agentId,
zoneId
};
}

View File

@@ -0,0 +1,13 @@
export const officeBadges = [
{ id: "lead", label: "Mazlum Bey", tone: "amber" },
{ id: "frontend", label: "Frontend Kanka", tone: "cyan" },
{ id: "backend", label: "Backend Kanka", tone: "green" },
{ id: "ios", label: "iOS Kanka", tone: "cyan" },
{ id: "ui", label: "UI Hanim", tone: "amber" }
];
export const officeNotes = [
"Patron icin hizli briefing hazir.",
"3D sahne: masa, ekranlar, duvar notlari, neon akis.",
"Office modu sadece gorunum degistirir; veri akisi ayni kalir."
];

View File

@@ -0,0 +1,8 @@
export const OFFICE_STATIONS = [
{ id: "mazlum", name: "Mazlum Bey", role: "Team Lead", debug: "BRIEFING NODE", x: 548, y: 196, tone: "amber" },
{ id: "berkecan", name: "Berkecan", role: "Frontend", debug: "UI BENCH", x: 264, y: 392, tone: "cyan" },
{ id: "simsar", name: "Simsar", role: "Backend", debug: "API BENCH", x: 820, y: 388, tone: "green" },
{ id: "aybuke", name: "UI Hanim", role: "Design Wall", debug: "UX BOARD", x: 250, y: 190, tone: "amber" },
{ id: "ive", name: "Ive", role: "iOS Bay", debug: "MOBILE RACK", x: 842, y: 178, tone: "cyan" },
{ id: "irgatov", name: "Irgatov", role: "Coffee Station", debug: "SERVICE DESK", x: 548, y: 562, tone: "red" }
];

View File

@@ -0,0 +1,62 @@
export const OFFICE_ZONES = [
{
id: "teamLeadDesk",
label: "Team Lead Desk",
role: "Team Lead",
type: "desk",
position: [0, 0, -4.2],
approachPosition: [0, 0, -5.25]
},
{
id: "frontendDesk",
label: "Frontend Desk",
role: "Frontend Dev",
type: "desk",
position: [-3.8, 0, -0.9],
approachPosition: [-3.8, 0, -1.95]
},
{
id: "backendDesk",
label: "Backend Desk",
role: "Backend Dev",
type: "desk",
position: [3.8, 0, -0.9],
approachPosition: [3.8, 0, -1.95]
},
{
id: "uiuxDesk",
label: "UI/UX Desk",
role: "UI/UX Designer",
type: "desk",
position: [-3.8, 0, 2.9],
approachPosition: [-3.8, 0, 1.85]
},
{
id: "iosDesk",
label: "iOS Desk",
role: "iOS Dev",
type: "desk",
position: [3.8, 0, 2.9],
approachPosition: [3.8, 0, 1.85]
},
{
id: "meetingTable",
label: "Meeting Table",
role: "Meeting",
type: "meeting",
position: [6.4, 0, -4],
approachPosition: [5, 0, -3.9]
},
{
id: "coffeeMachine",
label: "Coffee Machine",
role: "Utility",
type: "utility",
position: [6.8, 0, 4],
approachPosition: [5.7, 0, 4]
}
];
export function getZoneById(id) {
return OFFICE_ZONES.find((zone) => zone.id === id) ?? null;
}

View File

@@ -0,0 +1,22 @@
export default function Chair({ position = [0, 0, 0], rotation = [0, 0, 0] }) {
return (
<group position={position} rotation={rotation}>
<mesh castShadow receiveShadow position={[0, 0.42, 0]}>
<boxGeometry args={[0.7, 0.12, 0.7]} />
<meshStandardMaterial color="#6e719c" roughness={0.9} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.9, -0.24]}>
<boxGeometry args={[0.7, 0.84, 0.12]} />
<meshStandardMaterial color="#6e719c" roughness={0.9} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.2, 0]}>
<cylinderGeometry args={[0.07, 0.07, 0.42, 10]} />
<meshStandardMaterial color="#3d4259" roughness={0.8} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.04, 0]}>
<cylinderGeometry args={[0.32, 0.22, 0.08, 12]} />
<meshStandardMaterial color="#32364a" roughness={0.8} />
</mesh>
</group>
);
}

View File

@@ -0,0 +1,166 @@
import { Html } from "@react-three/drei";
function Cup({ position = [0, 0, 0], scale = 1, sleeve = false }) {
return (
<group position={position} scale={scale}>
<mesh castShadow position={[0, 0.18, 0]}>
<cylinderGeometry args={[0.09, 0.12, 0.28, 12]} />
<meshStandardMaterial color="#f8f8f4" roughness={0.92} />
</mesh>
<mesh castShadow position={[0, 0.335, 0]}>
<cylinderGeometry args={[0.11, 0.11, 0.05, 12]} />
<meshStandardMaterial color="#f4f4ef" roughness={0.9} />
</mesh>
{sleeve ? (
<mesh castShadow position={[0, 0.18, 0.095]} rotation={[0, 0, Math.PI / 2]}>
<boxGeometry args={[0.15, 0.08, 0.02]} />
<meshStandardMaterial color="#8d6239" roughness={0.95} />
</mesh>
) : null}
<mesh castShadow position={[0, 0.18, 0.105]} rotation={[0, 0, Math.PI / 2]}>
<cylinderGeometry args={[0.03, 0.03, sleeve ? 0.08 : 0.1, 18]} />
<meshStandardMaterial color="#1f8b56" roughness={0.82} />
</mesh>
</group>
);
}
function TopCups() {
const topCupPositions = [
[-0.38, 1.55, -0.14],
[-0.16, 1.55, -0.14],
[0.06, 1.55, -0.14],
[0.28, 1.55, -0.14],
[-0.27, 1.55, 0.06],
[-0.05, 1.55, 0.06],
[0.17, 1.55, 0.06]
];
return topCupPositions.map((position, index) => (
<Cup key={index} position={position} scale={0.75} />
));
}
function FrontCups() {
return (
<group>
<Cup position={[-0.28, 0.42, 0.85]} scale={1.15} />
<Cup position={[-0.06, 0.42, 0.82]} scale={1.05} />
<Cup position={[0.24, 0.42, 0.83]} scale={0.82} sleeve />
<mesh castShadow position={[0.55, 0.44, 0.82]}>
<boxGeometry args={[0.17, 0.12, 0.17]} />
<meshStandardMaterial color="#9a6d41" roughness={0.94} />
</mesh>
<mesh castShadow position={[0.76, 0.44, 0.78]} rotation={[0, -0.25, 0]}>
<boxGeometry args={[0.14, 0.04, 0.16]} />
<meshStandardMaterial color="#b18458" roughness={0.95} />
</mesh>
</group>
);
}
export default function CoffeeMachine({ position = [0, 0, 0], selected = false, onSelect }) {
return (
<group position={position} scale={0.82}>
<mesh
position={[0, 0.7, 0.12]}
onPointerDown={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<boxGeometry args={[2.3, 1.7, 1.8]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
{selected ? (
<>
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.015, 0.12]}>
<ringGeometry args={[1.15, 1.35, 40]} />
<meshBasicMaterial color="#8fe7ff" transparent opacity={0.75} />
</mesh>
<Html position={[0, 2.15, 0.12]} center sprite>
<div className="office-object__label">Kahve Makinesi</div>
</Html>
</>
) : null}
<mesh receiveShadow position={[0, 0.22, 0.12]}>
<boxGeometry args={[2.25, 0.36, 1.65]} />
<meshStandardMaterial color="#6e4024" roughness={0.98} />
</mesh>
<mesh receiveShadow position={[0, 0.42, 0.12]}>
<boxGeometry args={[2.05, 0.08, 1.45]} />
<meshStandardMaterial color="#4b2918" roughness={0.95} />
</mesh>
<mesh castShadow receiveShadow position={[0, 1.02, 0]}>
<boxGeometry args={[1.55, 0.92, 0.95]} />
<meshStandardMaterial color="#20232a" metalness={0.08} roughness={0.62} />
</mesh>
<mesh castShadow receiveShadow position={[0, 1.12, 0.18]}>
<boxGeometry args={[1.42, 0.78, 0.52]} />
<meshStandardMaterial color="#a7adb5" metalness={0.58} roughness={0.28} />
</mesh>
<mesh castShadow receiveShadow position={[0, 1.58, -0.04]}>
<boxGeometry args={[1.38, 0.08, 0.82]} />
<meshStandardMaterial color="#42464d" metalness={0.35} roughness={0.42} />
</mesh>
<mesh castShadow receiveShadow position={[0, 1.61, -0.04]}>
<boxGeometry args={[1.18, 0.025, 0.62]} />
<meshStandardMaterial color="#181a1f" roughness={0.85} />
</mesh>
<mesh castShadow position={[0, 1.22, 0.45]}>
<boxGeometry args={[0.98, 0.18, 0.06]} />
<meshStandardMaterial color="#111315" roughness={0.55} />
</mesh>
<mesh castShadow position={[-0.26, 1.22, 0.49]}>
<boxGeometry args={[0.26, 0.08, 0.02]} />
<meshStandardMaterial color="#5cf0b1" emissive="#29b787" emissiveIntensity={0.3} />
</mesh>
<mesh castShadow position={[0.26, 1.22, 0.49]}>
<boxGeometry args={[0.26, 0.08, 0.02]} />
<meshStandardMaterial color="#66d5ff" emissive="#3d9fd0" emissiveIntensity={0.3} />
</mesh>
<mesh castShadow position={[0, 1.22, 0.49]}>
<cylinderGeometry args={[0.09, 0.09, 0.03, 18]} />
<meshStandardMaterial color="#d8ddd8" metalness={0.35} roughness={0.25} />
</mesh>
<mesh castShadow position={[-0.6, 1.24, 0.46]}>
<cylinderGeometry args={[0.07, 0.07, 0.12, 14]} />
<meshStandardMaterial color="#181a1f" roughness={0.55} />
</mesh>
<mesh castShadow position={[0.6, 1.24, 0.46]}>
<cylinderGeometry args={[0.07, 0.07, 0.12, 14]} />
<meshStandardMaterial color="#181a1f" roughness={0.55} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.62, 0.26]}>
<boxGeometry args={[1.15, 0.08, 0.44]} />
<meshStandardMaterial color="#959aa1" metalness={0.58} roughness={0.25} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.67, 0.26]}>
<boxGeometry args={[0.96, 0.02, 0.3]} />
<meshStandardMaterial color="#4e5358" roughness={0.76} />
</mesh>
<mesh castShadow position={[-0.24, 0.94, 0.22]}>
<cylinderGeometry args={[0.04, 0.03, 0.22, 12]} />
<meshStandardMaterial color="#b5bac1" metalness={0.7} roughness={0.2} />
</mesh>
<mesh castShadow position={[0.24, 0.94, 0.22]}>
<cylinderGeometry args={[0.04, 0.03, 0.22, 12]} />
<meshStandardMaterial color="#b5bac1" metalness={0.7} roughness={0.2} />
</mesh>
<mesh castShadow position={[-0.24, 0.78, 0.36]}>
<boxGeometry args={[0.24, 0.05, 0.08]} />
<meshStandardMaterial color="#111315" roughness={0.6} />
</mesh>
<mesh castShadow position={[0.24, 0.78, 0.36]}>
<boxGeometry args={[0.24, 0.05, 0.08]} />
<meshStandardMaterial color="#111315" roughness={0.6} />
</mesh>
<TopCups />
<FrontCups />
</group>
);
}

View File

@@ -0,0 +1,67 @@
import { useLoader } from "@react-three/fiber";
import { TextureLoader } from "three";
import { DoubleSide } from "three";
import Chair from "./Chair.jsx";
export default function Desk({ position = [0, 0, 0], nameplateColor = "#52b6ff", onSelect }) {
const appleLogoTexture = useLoader(TextureLoader, "/apple-logo.png");
return (
<group
position={position}
onPointerDown={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<mesh position={[0, 0.72, -0.08]}>
<boxGeometry args={[2.1, 1.5, 2.1]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.78, 0]}>
<boxGeometry args={[1.9, 0.16, 1.15]} />
<meshStandardMaterial color="#b78256" roughness={0.86} />
</mesh>
{[
[-0.78, 0.38, -0.43],
[0.78, 0.38, -0.43],
[-0.78, 0.38, 0.43],
[0.78, 0.38, 0.43]
].map((leg, index) => (
<mesh key={index} castShadow receiveShadow position={leg}>
<boxGeometry args={[0.12, 0.76, 0.12]} />
<meshStandardMaterial color="#8e603b" roughness={0.9} />
</mesh>
))}
<group position={[0, 0.92, 0]} rotation={[0, Math.PI, 0]}>
<mesh castShadow receiveShadow position={[0, 0.02, 0.02]}>
<boxGeometry args={[0.64, 0.04, 0.42]} />
<meshStandardMaterial color="#c3c9d2" roughness={0.36} metalness={0.52} />
</mesh>
<group position={[0, 0.055, -0.19]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh castShadow receiveShadow position={[0, 0, 0.2]}>
<boxGeometry args={[0.62, 0.03, 0.4]} />
<meshStandardMaterial color="#c8ced6" roughness={0.28} metalness={0.58} />
</mesh>
<mesh position={[0, 0.022, 0.2]} rotation={[-Math.PI / 2, 0, Math.PI]}>
<planeGeometry args={[0.18, 0.18]} />
<meshBasicMaterial
map={appleLogoTexture}
transparent
alphaTest={0.1}
side={DoubleSide}
toneMapped={false}
/>
</mesh>
</group>
</group>
<mesh castShadow receiveShadow position={[0.65, 0.9, 0.18]}>
<boxGeometry args={[0.26, 0.1, 0.26]} />
<meshStandardMaterial color={nameplateColor} roughness={0.55} />
</mesh>
<Chair position={[0, 0, -1.05]} />
</group>
);
}

View File

@@ -0,0 +1,14 @@
export default function MeetingTable({ position = [0, 0, 0] }) {
return (
<group position={position}>
<mesh castShadow receiveShadow position={[0, 0.8, 0]}>
<cylinderGeometry args={[1.55, 1.55, 0.18, 28]} />
<meshStandardMaterial color="#bf8d61" roughness={0.88} />
</mesh>
<mesh castShadow receiveShadow position={[0, 0.36, 0]}>
<cylinderGeometry args={[0.22, 0.28, 0.72, 16]} />
<meshStandardMaterial color="#8e603b" roughness={0.86} />
</mesh>
</group>
);
}

1180
web/src/styles/app.css Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
}
});