@neolio42/pixel-office 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin.sh +16 -0
- package/bin.ts +162 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +7 -0
- package/package.json +51 -0
- package/postcss.config.mjs +7 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/characters.png +0 -0
- package/public/assets/default-layout-1.json +92 -0
- package/public/assets/floors/floor_0.png +0 -0
- package/public/assets/floors/floor_1.png +0 -0
- package/public/assets/floors/floor_2.png +0 -0
- package/public/assets/floors/floor_3.png +0 -0
- package/public/assets/floors/floor_4.png +0 -0
- package/public/assets/floors/floor_5.png +0 -0
- package/public/assets/floors/floor_6.png +0 -0
- package/public/assets/floors/floor_7.png +0 -0
- package/public/assets/floors/floor_8.png +0 -0
- package/public/assets/furniture/BIN/BIN.png +0 -0
- package/public/assets/furniture/BIN/manifest.json +13 -0
- package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
- package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
- package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
- package/public/assets/furniture/CACTUS/manifest.json +13 -0
- package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
- package/public/assets/furniture/CLOCK/manifest.json +13 -0
- package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
- package/public/assets/furniture/COFFEE/manifest.json +13 -0
- package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
- package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
- package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
- package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
- package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
- package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
- package/public/assets/furniture/DESK/manifest.json +33 -0
- package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
- package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
- package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
- package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
- package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
- package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
- package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
- package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
- package/public/assets/furniture/PC/PC_BACK.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
- package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
- package/public/assets/furniture/PC/PC_SIDE.png +0 -0
- package/public/assets/furniture/PC/manifest.json +88 -0
- package/public/assets/furniture/PLANT/PLANT.png +0 -0
- package/public/assets/furniture/PLANT/manifest.json +13 -0
- package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
- package/public/assets/furniture/PLANT_2/manifest.json +13 -0
- package/public/assets/furniture/POT/POT.png +0 -0
- package/public/assets/furniture/POT/manifest.json +13 -0
- package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
- package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
- package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
- package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
- package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
- package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
- package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
- package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
- package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
- package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
- package/public/assets/furniture/SOFA/manifest.json +44 -0
- package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
- package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
- package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
- package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
- package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
- package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
- package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
- package/public/assets/walls/wall_0.png +0 -0
- package/scripts/setup.ts +158 -0
- package/server.ts +53 -0
- package/src/app/api/focus-terminal/route.ts +65 -0
- package/src/app/api/hooks/notification/route.ts +19 -0
- package/src/app/api/hooks/post-tool-use/route.ts +26 -0
- package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
- package/src/app/api/hooks/session-end/route.ts +31 -0
- package/src/app/api/hooks/session-start/route.ts +47 -0
- package/src/app/api/hooks/stop/route.ts +19 -0
- package/src/app/api/hooks/user-prompt/route.ts +92 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +21 -0
- package/src/app/page.tsx +5 -0
- package/src/components/ApprovalToast.tsx +132 -0
- package/src/components/OfficeCanvas.tsx +311 -0
- package/src/components/Terminal.tsx +177 -0
- package/src/components/TerminalTile.tsx +181 -0
- package/src/components/WorkerPanel.tsx +261 -0
- package/src/components/WorkerPopup.tsx +116 -0
- package/src/game/asset-loader.ts +172 -0
- package/src/game/office-layout.ts +287 -0
- package/src/game/renderer.ts +369 -0
- package/src/game/sprites.ts +133 -0
- package/src/game/worker-entity.ts +219 -0
- package/src/hooks/usePixelOffice.ts +318 -0
- package/src/hooks/useRecentCwds.ts +27 -0
- package/src/lib/approval-queue.ts +67 -0
- package/src/lib/pty-manager.ts +267 -0
- package/src/lib/store.ts +181 -0
- package/src/lib/tool-classifier.ts +224 -0
- package/src/lib/transcript.ts +109 -0
- package/src/lib/types.ts +58 -0
- package/src/lib/ws-server.ts +270 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSession, addSession, setSessionFocus, setSessionTask } from '@/lib/store';
|
|
3
|
+
import { broadcast } from '@/lib/ws-server';
|
|
4
|
+
|
|
5
|
+
/** Action verbs for finding actionable sentences. */
|
|
6
|
+
const ACTION_RE = /\b(fix|add|remove|delet|creat|updat|build|clean|mak|implement|refactor|debug|check|test|writ|mov|renam|chang|set|configur|deploy|push|install|upgrad|migrat|convert|pars|extract|handl|show|hid|enabl|disabl|run|start|stop|appl|open|clos|review|audit|verif|ensur|improv|optimiz|rewrit|redesign|simplif|merg|split|connect|hook|scaffold|setup|integrat|strip|display|render|read|edit|search|reload|restart|clear|increas|look\s*at|work\s*on|clean\s*up|set\s*up|figure\s*out)\w*\b/i;
|
|
7
|
+
|
|
8
|
+
const STRIP = [
|
|
9
|
+
/^.*?\bhow\s+about\s+(we\s+)?/i,
|
|
10
|
+
/^.*?\blet'?s\s+(just\s+)?/i,
|
|
11
|
+
/^.*?\bwe\s+(need|have|should|could|want)\s+to\s+/i,
|
|
12
|
+
/^.*?\bi\s+(need|want)\s+you\s+to\s+/i,
|
|
13
|
+
/^.*?\b(can|could|would)\s+you\s+(please\s+)?/i,
|
|
14
|
+
/^(right|ok|okay|alright|so|now|first|also|then|next|and|but|like|dude|honestly|basically|well)\s*(,\s*|\s+)/i,
|
|
15
|
+
/^(you\s+know,?\s*)/i,
|
|
16
|
+
/^(we'?re\s+trying\s+to\s+)/i,
|
|
17
|
+
/^(first\s+of\s+all|in\s+addition(\s+to\s+that)?)\s*(,\s*|\s+)/i,
|
|
18
|
+
/^(please\s+)/i,
|
|
19
|
+
/^(make\s+sure\s+(that\s+)?(we\s+)?(don'?t\s+)?)/i,
|
|
20
|
+
/^(I\s+think\s+(we\s+should\s+)?)/i,
|
|
21
|
+
/^(we\s+need\s+to\s+)/i,
|
|
22
|
+
/^(implement\s+the\s+following\s+(plan|task|request):\s*)/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Quick heuristic focus from user prompt — used as fallback until transcript is read. */
|
|
26
|
+
export function extractPromptFocus(prompt: string): string | null {
|
|
27
|
+
// Split into sentences
|
|
28
|
+
const sentences = prompt.split(/(?<=[.!?\n])\s+/)
|
|
29
|
+
.map(s => s.trim())
|
|
30
|
+
.filter(s => s.length > 8);
|
|
31
|
+
|
|
32
|
+
// Skip noise sentences
|
|
33
|
+
function isNoise(s: string): boolean {
|
|
34
|
+
if (s.startsWith('```') || s.startsWith('---') || s.startsWith('<')) return true;
|
|
35
|
+
if (!ACTION_RE.test(s)) {
|
|
36
|
+
if (/^(right|ok|okay|yeah|yea|no|yes|sure|dude|honestly|it'?s|that'?s|this is|not|how many|why|what the|I don'?t|we'?re|I'?m|you'?re)\b/i.test(s)) return true;
|
|
37
|
+
if (s.endsWith('?')) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Find first actionable sentence
|
|
43
|
+
let chosen = sentences.find(s => !isNoise(s) && ACTION_RE.test(s));
|
|
44
|
+
if (!chosen) chosen = sentences.find(s => !isNoise(s));
|
|
45
|
+
if (!chosen) return null;
|
|
46
|
+
|
|
47
|
+
// Clean it
|
|
48
|
+
for (const p of STRIP) {
|
|
49
|
+
chosen = chosen.replace(p, '');
|
|
50
|
+
}
|
|
51
|
+
chosen = chosen.replace(/[.!:]+$/, '').replace(/\s+(and\s+shit|and\s+stuff|or\s+whatever)$/i, '').trim();
|
|
52
|
+
|
|
53
|
+
if (chosen.length < 5) return null;
|
|
54
|
+
chosen = chosen[0].toUpperCase() + chosen.slice(1);
|
|
55
|
+
|
|
56
|
+
return chosen;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function POST(req: NextRequest) {
|
|
60
|
+
const body = await req.json();
|
|
61
|
+
const sessionId = body.session_id;
|
|
62
|
+
const prompt = body.prompt || '';
|
|
63
|
+
const cwd = body.cwd || '';
|
|
64
|
+
|
|
65
|
+
if (!sessionId || !prompt || prompt.length < 5 || prompt.startsWith('<')) {
|
|
66
|
+
return NextResponse.json({ status: 'ok' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (/^(continue|yes|no|ok|sure|go|y|n|done|looks good|lgtm)\s*[.!?]*$/i.test(prompt.trim())) {
|
|
70
|
+
return NextResponse.json({ status: 'ok' });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!getSession(sessionId)) addSession(sessionId, cwd);
|
|
74
|
+
|
|
75
|
+
const session = getSession(sessionId);
|
|
76
|
+
if (!session) return NextResponse.json({ status: 'ok' });
|
|
77
|
+
|
|
78
|
+
// Set heuristic focus from prompt as placeholder
|
|
79
|
+
// PreToolUse will try to upgrade this from the transcript
|
|
80
|
+
const promptFocus = extractPromptFocus(prompt);
|
|
81
|
+
session.needsFocusUpdate = true;
|
|
82
|
+
if (promptFocus) {
|
|
83
|
+
setSessionFocus(sessionId, promptFocus);
|
|
84
|
+
} else {
|
|
85
|
+
session.currentFocus = undefined;
|
|
86
|
+
}
|
|
87
|
+
broadcast({ type: 'session-update', session });
|
|
88
|
+
|
|
89
|
+
if (!session.task) setSessionTask(sessionId, prompt.slice(0, 120));
|
|
90
|
+
|
|
91
|
+
return NextResponse.json({ status: 'ok' });
|
|
92
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import "./globals.css";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: "Pixel Office",
|
|
6
|
+
description: "Watch your Claude Code sessions as pixel art workers",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: Readonly<{
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}>) {
|
|
14
|
+
return (
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<body className="bg-[#1a1a2e] text-white overflow-hidden">
|
|
17
|
+
{children}
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
20
|
+
);
|
|
21
|
+
}
|
package/src/app/page.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ApprovalRequest } from '@/hooks/usePixelOffice';
|
|
4
|
+
import { Session } from '@/lib/types';
|
|
5
|
+
|
|
6
|
+
const WORKER_NAMES = ['Pixel', 'Byte', 'Cache', 'Queue', 'Stack'];
|
|
7
|
+
|
|
8
|
+
/** Clean up a tool name for display — strips mcp__ prefix and underscores. */
|
|
9
|
+
function cleanToolName(toolName: string): string {
|
|
10
|
+
const mcpMatch = toolName.match(/^mcp__([^_]+(?:_[^_]+)*)__(.+)$/);
|
|
11
|
+
if (mcpMatch) {
|
|
12
|
+
const server = mcpMatch[1].replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
13
|
+
const action = mcpMatch[2].replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
14
|
+
return `${server}: ${action}`;
|
|
15
|
+
}
|
|
16
|
+
return toolName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatToolDetails(toolName: string, toolInput: Record<string, unknown>): { title: string; details: string[] } {
|
|
20
|
+
if (toolName === 'Bash' || toolName === 'BashOutput') {
|
|
21
|
+
const cmd = String(toolInput.command || '').trim();
|
|
22
|
+
return {
|
|
23
|
+
title: 'Run command',
|
|
24
|
+
details: [cmd],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'MultiEdit') {
|
|
29
|
+
const path = String(toolInput.file_path || '');
|
|
30
|
+
const shortPath = path.split('/').slice(-3).join('/');
|
|
31
|
+
return {
|
|
32
|
+
title: `${toolName} file`,
|
|
33
|
+
details: [shortPath],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (toolName === 'Read') {
|
|
38
|
+
const path = String(toolInput.file_path || '');
|
|
39
|
+
const shortPath = path.split('/').slice(-3).join('/');
|
|
40
|
+
return {
|
|
41
|
+
title: 'Read file',
|
|
42
|
+
details: [shortPath],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MCP or unknown tool: clean up the name and show string input values
|
|
47
|
+
const title = cleanToolName(toolName);
|
|
48
|
+
const details: string[] = [];
|
|
49
|
+
for (const [key, value] of Object.entries(toolInput)) {
|
|
50
|
+
if (typeof value === 'string') {
|
|
51
|
+
details.push(`${key}: ${value.length > 120 ? value.slice(0, 120) + '…' : value}`);
|
|
52
|
+
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
53
|
+
details.push(`${key}: ${value}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (details.length === 0) {
|
|
57
|
+
details.push(JSON.stringify(toolInput).slice(0, 200));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { title, details };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Props {
|
|
64
|
+
approval: ApprovalRequest;
|
|
65
|
+
session?: Session;
|
|
66
|
+
onDecision: (approvalId: string, decision: 'allow' | 'deny') => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ApprovalToast({ approval, session, onDecision }: Props) {
|
|
70
|
+
const { title, details } = formatToolDetails(approval.toolName, approval.toolInput);
|
|
71
|
+
const workerName = session ? (WORKER_NAMES[session.deskIndex] ?? `Worker ${session.deskIndex}`) : null;
|
|
72
|
+
const cwdShort = session ? session.cwd.split('/').slice(-2).join('/') : null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="bg-[#1a1a2e] border border-[#bf8b4a]/60 rounded-lg shadow-2xl min-w-[420px] max-w-[580px] overflow-hidden">
|
|
76
|
+
{/* Header */}
|
|
77
|
+
<div className="px-4 py-2.5 bg-[#bf8b4a]/10 border-b border-[#bf8b4a]/20 flex items-center justify-between">
|
|
78
|
+
<div className="flex items-center gap-2">
|
|
79
|
+
<div className="w-2 h-2 rounded-full bg-[#bf8b4a] animate-pulse" />
|
|
80
|
+
<span className="text-[#bf8b4a] text-xs font-mono font-bold uppercase tracking-wider">
|
|
81
|
+
Approval Required
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
{workerName && (
|
|
85
|
+
<span className="text-[#6a6a8a] text-xs font-mono">
|
|
86
|
+
{workerName}{cwdShort ? ` · ${cwdShort}` : ''}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Task context */}
|
|
92
|
+
{session?.task && (
|
|
93
|
+
<div className="px-4 pt-2 pb-0">
|
|
94
|
+
<div className="text-[#8899aa] text-xs font-mono truncate" title={session.task}>
|
|
95
|
+
{session.task.replace(/^[\s—–\-•*]+/, '')}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{/* Content */}
|
|
101
|
+
<div className="px-4 py-3">
|
|
102
|
+
<div className="text-[#ccccee] text-sm font-mono font-bold mb-2">
|
|
103
|
+
{title}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="bg-[#0e0e1a] rounded border border-[#2a2a4a] p-2.5 mb-3 max-h-[120px] overflow-y-auto">
|
|
106
|
+
{details.map((line, i) => (
|
|
107
|
+
<div key={i} className="text-[#9999bb] text-xs font-mono break-all whitespace-pre-wrap leading-relaxed">
|
|
108
|
+
{line}
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Actions */}
|
|
115
|
+
<div className="px-4 pb-3 flex gap-2">
|
|
116
|
+
<button
|
|
117
|
+
onClick={() => onDecision(approval.id, 'allow')}
|
|
118
|
+
className="flex-1 px-4 py-2.5 bg-[#1a3a2a] hover:bg-[#2a5a3a] border border-[#2a6b3a] text-[#4abf5c] text-sm font-mono font-bold rounded transition-colors cursor-pointer flex items-center justify-center gap-2"
|
|
119
|
+
>
|
|
120
|
+
<span>Approve</span>
|
|
121
|
+
<span className="text-[#4abf5c]/60 text-xs">⌘Y</span>
|
|
122
|
+
</button>
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => onDecision(approval.id, 'deny')}
|
|
125
|
+
className="flex-1 px-4 py-2.5 bg-[#3a1a1a] hover:bg-[#5a2a2a] border border-[#6b2a2a] text-[#e05c5c] text-sm font-mono font-bold rounded transition-colors cursor-pointer"
|
|
126
|
+
>
|
|
127
|
+
Deny
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useRecentCwds } from '@/hooks/useRecentCwds';
|
|
5
|
+
import { CANVAS_W, CANVAS_H, TILE_SIZE, SCALE } from '@/game/office-layout';
|
|
6
|
+
import { usePixelOffice } from '@/hooks/usePixelOffice';
|
|
7
|
+
import { WorkerPanel } from './WorkerPanel';
|
|
8
|
+
import { ApprovalToast } from './ApprovalToast';
|
|
9
|
+
import { WorkerPopup } from './WorkerPopup';
|
|
10
|
+
import { TerminalTile, EmptyTile } from './TerminalTile';
|
|
11
|
+
|
|
12
|
+
/** Max terminals visible in the grid at once */
|
|
13
|
+
const MAX_TILES = 3;
|
|
14
|
+
|
|
15
|
+
export function OfficeCanvas() {
|
|
16
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
17
|
+
const {
|
|
18
|
+
sessions, approvals, sendApproval, assetsLoaded,
|
|
19
|
+
selectedWorker, setSelectedWorker, workersRef,
|
|
20
|
+
wsRef, ptyTabs, setPtyTabs, spawnSession, spawnError, terminalHandlersRef, onSpawnSuccessRef,
|
|
21
|
+
} = usePixelOffice(canvasRef);
|
|
22
|
+
const [popupAnchor, setPopupAnchor] = useState<{ x: number; y: number } | null>(null);
|
|
23
|
+
const [viewportSize, setViewportSize] = useState({ w: 0, h: 0 });
|
|
24
|
+
|
|
25
|
+
// Which ptyIds are pinned to visible tiles (up to MAX_TILES)
|
|
26
|
+
const [visiblePtyIds, setVisiblePtyIds] = useState<string[]>([]);
|
|
27
|
+
|
|
28
|
+
// Sync visible tiles when tabs change
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setVisiblePtyIds(prev => {
|
|
31
|
+
// Remove any that no longer exist in ptyTabs
|
|
32
|
+
const filtered = prev.filter(id => ptyTabs.some(t => t.ptyId === id));
|
|
33
|
+
// Auto-add new tabs if there's room
|
|
34
|
+
for (const tab of ptyTabs) {
|
|
35
|
+
if (filtered.length >= MAX_TILES) break;
|
|
36
|
+
if (!filtered.includes(tab.ptyId)) {
|
|
37
|
+
filtered.push(tab.ptyId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (filtered.length === prev.length && filtered.every((id, i) => prev[i] === id)) return prev;
|
|
41
|
+
return filtered;
|
|
42
|
+
});
|
|
43
|
+
}, [ptyTabs]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const update = () => setViewportSize({ w: window.innerWidth, h: window.innerHeight });
|
|
47
|
+
update();
|
|
48
|
+
window.addEventListener('resize', update);
|
|
49
|
+
return () => window.removeEventListener('resize', update);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const openTerminal = useCallback((ptyId: string) => {
|
|
53
|
+
// Ensure tab exists in ptyTabs (may have been removed on close)
|
|
54
|
+
setPtyTabs(prev => prev.some(t => t.ptyId === ptyId) ? prev : [...prev, { ptyId, cwd: '', exited: false }]);
|
|
55
|
+
setVisiblePtyIds(prev => {
|
|
56
|
+
if (prev.includes(ptyId)) return prev;
|
|
57
|
+
if (prev.length < MAX_TILES) return [...prev, ptyId];
|
|
58
|
+
return [...prev.slice(0, -1), ptyId];
|
|
59
|
+
});
|
|
60
|
+
}, [setPtyTabs]);
|
|
61
|
+
|
|
62
|
+
const closeTerminalTile = useCallback((ptyId: string) => {
|
|
63
|
+
setPtyTabs(prev => prev.filter(t => t.ptyId !== ptyId));
|
|
64
|
+
setVisiblePtyIds(prev => prev.filter(id => id !== ptyId));
|
|
65
|
+
}, [setPtyTabs]);
|
|
66
|
+
|
|
67
|
+
const { saveRecent } = useRecentCwds();
|
|
68
|
+
const pendingSpawnCwdRef = useRef<string | null>(null);
|
|
69
|
+
|
|
70
|
+
const handleSpawn = useCallback((cwd: string) => {
|
|
71
|
+
pendingSpawnCwdRef.current = cwd;
|
|
72
|
+
spawnSession(cwd);
|
|
73
|
+
}, [spawnSession]);
|
|
74
|
+
|
|
75
|
+
// Save to recents only on successful spawn
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
onSpawnSuccessRef.current = () => {
|
|
78
|
+
if (pendingSpawnCwdRef.current) {
|
|
79
|
+
saveRecent(pendingSpawnCwdRef.current);
|
|
80
|
+
pendingSpawnCwdRef.current = null;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}, [saveRecent, onSpawnSuccessRef]);
|
|
84
|
+
|
|
85
|
+
// Drag-to-swap tile reordering
|
|
86
|
+
const dragSourceRef = useRef<string | null>(null);
|
|
87
|
+
|
|
88
|
+
const handleTileDragStart = useCallback((ptyId: string) => {
|
|
89
|
+
dragSourceRef.current = ptyId;
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const handleTileDragOver = useCallback((e: React.DragEvent) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
e.dataTransfer.dropEffect = 'move';
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleTileDrop = useCallback((targetPtyId: string) => {
|
|
98
|
+
const sourcePtyId = dragSourceRef.current;
|
|
99
|
+
if (!sourcePtyId || sourcePtyId === targetPtyId) return;
|
|
100
|
+
setVisiblePtyIds(prev => {
|
|
101
|
+
const sourceIdx = prev.indexOf(sourcePtyId);
|
|
102
|
+
const targetIdx = prev.indexOf(targetPtyId);
|
|
103
|
+
if (targetIdx < 0) return prev;
|
|
104
|
+
// Source is from panel (not visible) — replace target
|
|
105
|
+
if (sourceIdx < 0) {
|
|
106
|
+
const next = [...prev];
|
|
107
|
+
next[targetIdx] = sourcePtyId;
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
// Both visible — swap
|
|
111
|
+
const next = [...prev];
|
|
112
|
+
next[sourceIdx] = targetPtyId;
|
|
113
|
+
next[targetIdx] = sourcePtyId;
|
|
114
|
+
return next;
|
|
115
|
+
});
|
|
116
|
+
dragSourceRef.current = null;
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const handleCanvasClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
120
|
+
const canvas = canvasRef.current;
|
|
121
|
+
if (!canvas) return;
|
|
122
|
+
|
|
123
|
+
const rect = canvas.getBoundingClientRect();
|
|
124
|
+
const displayW = rect.width;
|
|
125
|
+
const displayH = rect.height;
|
|
126
|
+
|
|
127
|
+
const scaleX = displayW / CANVAS_W;
|
|
128
|
+
const scaleY = displayH / CANVAS_H;
|
|
129
|
+
const fitScale = Math.min(scaleX, scaleY);
|
|
130
|
+
|
|
131
|
+
const renderedW = CANVAS_W * fitScale;
|
|
132
|
+
const renderedH = CANVAS_H * fitScale;
|
|
133
|
+
const offsetX = (displayW - renderedW) / 2;
|
|
134
|
+
const offsetY = (displayH - renderedH) / 2;
|
|
135
|
+
|
|
136
|
+
const clickX = e.clientX - rect.left - offsetX;
|
|
137
|
+
const clickY = e.clientY - rect.top - offsetY;
|
|
138
|
+
|
|
139
|
+
const logicalX = clickX / fitScale;
|
|
140
|
+
const logicalY = clickY / fitScale;
|
|
141
|
+
const tileX = logicalX / (TILE_SIZE * SCALE);
|
|
142
|
+
const tileY = logicalY / (TILE_SIZE * SCALE);
|
|
143
|
+
|
|
144
|
+
const CLICK_RADIUS = 1.5;
|
|
145
|
+
let closest: string | null = null;
|
|
146
|
+
let closestDist = CLICK_RADIUS;
|
|
147
|
+
|
|
148
|
+
for (const [id, worker] of workersRef.current) {
|
|
149
|
+
const dist = Math.sqrt((worker.x - tileX) ** 2 + (worker.y - tileY) ** 2);
|
|
150
|
+
if (dist < closestDist) {
|
|
151
|
+
closestDist = dist;
|
|
152
|
+
closest = id;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (closest) {
|
|
157
|
+
setSelectedWorker(closest);
|
|
158
|
+
const worker = workersRef.current.get(closest)!;
|
|
159
|
+
const workerLogicalX = worker.x * TILE_SIZE * SCALE;
|
|
160
|
+
const workerLogicalY = worker.y * TILE_SIZE * SCALE;
|
|
161
|
+
const vpX = rect.left + offsetX + workerLogicalX * fitScale;
|
|
162
|
+
const vpY = rect.top + offsetY + workerLogicalY * fitScale;
|
|
163
|
+
setPopupAnchor({ x: vpX, y: vpY });
|
|
164
|
+
} else {
|
|
165
|
+
setSelectedWorker(null);
|
|
166
|
+
setPopupAnchor(null);
|
|
167
|
+
}
|
|
168
|
+
}, [workersRef, setSelectedWorker, sessions]);
|
|
169
|
+
|
|
170
|
+
const handleDismissPopup = useCallback(() => {
|
|
171
|
+
setSelectedWorker(null);
|
|
172
|
+
setPopupAnchor(null);
|
|
173
|
+
}, [setSelectedWorker]);
|
|
174
|
+
|
|
175
|
+
// ⌘Y keyboard shortcut — approve the oldest pending approval
|
|
176
|
+
const approvalsRef = useRef(approvals);
|
|
177
|
+
useEffect(() => { approvalsRef.current = approvals; }, [approvals]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const handler = (e: KeyboardEvent) => {
|
|
181
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
const oldest = approvalsRef.current[0];
|
|
184
|
+
if (oldest) sendApproval(oldest.id, 'allow');
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
window.addEventListener('keydown', handler);
|
|
188
|
+
return () => window.removeEventListener('keydown', handler);
|
|
189
|
+
}, [sendApproval]);
|
|
190
|
+
|
|
191
|
+
// Build the grid tiles: office + terminals + empty slots
|
|
192
|
+
const visibleTabs = visiblePtyIds
|
|
193
|
+
.map(id => ptyTabs.find(t => t.ptyId === id))
|
|
194
|
+
.filter((t): t is NonNullable<typeof t> => !!t);
|
|
195
|
+
|
|
196
|
+
const terminalCount = visibleTabs.length;
|
|
197
|
+
const showEmptyTile = terminalCount < MAX_TILES;
|
|
198
|
+
|
|
199
|
+
// Grid layout: 2x2 with office always top-left
|
|
200
|
+
// 0 terminals: office fills the whole area
|
|
201
|
+
// 1 terminal: office left, terminal right
|
|
202
|
+
// 2 terminals: office top-left, term1 top-right, term2 bottom spanning full
|
|
203
|
+
// 3 terminals: office top-left, term1 top-right, term2 bottom-left, term3 bottom-right
|
|
204
|
+
const totalTiles = terminalCount + (showEmptyTile ? 1 : 0);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className="flex h-screen w-screen bg-[#08080f] overflow-hidden">
|
|
208
|
+
{/* Main grid area */}
|
|
209
|
+
<div className={`flex-1 grid gap-[1px] p-[1px] min-w-0 ${
|
|
210
|
+
totalTiles === 0
|
|
211
|
+
? 'grid-cols-1 grid-rows-1'
|
|
212
|
+
: totalTiles <= 1
|
|
213
|
+
? 'grid-cols-2 grid-rows-1'
|
|
214
|
+
: 'grid-cols-2 grid-rows-2'
|
|
215
|
+
}`}>
|
|
216
|
+
{/* Office tile — always present */}
|
|
217
|
+
<div className={`relative flex items-center justify-center bg-[#0e0e1e] rounded overflow-hidden ${
|
|
218
|
+
totalTiles >= 3 ? '' : totalTiles === 2 ? '' : totalTiles === 0 ? 'col-span-2 row-span-2' : ''
|
|
219
|
+
}`}>
|
|
220
|
+
{!assetsLoaded && (
|
|
221
|
+
<div className="absolute inset-0 flex items-center justify-center z-20">
|
|
222
|
+
<span className="text-[#8888aa] font-mono text-sm animate-pulse">Loading assets…</span>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
<canvas
|
|
226
|
+
ref={canvasRef}
|
|
227
|
+
width={CANVAS_W}
|
|
228
|
+
height={CANVAS_H}
|
|
229
|
+
className="max-w-full max-h-full object-contain cursor-pointer"
|
|
230
|
+
style={{ imageRendering: 'pixelated', opacity: assetsLoaded ? 1 : 0 }}
|
|
231
|
+
onClick={handleCanvasClick}
|
|
232
|
+
/>
|
|
233
|
+
{/* Approval toasts */}
|
|
234
|
+
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex flex-col gap-2 z-30">
|
|
235
|
+
{approvals.map(approval => (
|
|
236
|
+
<ApprovalToast
|
|
237
|
+
key={approval.id}
|
|
238
|
+
approval={approval}
|
|
239
|
+
session={sessions.find(s => s.sessionId === approval.sessionId)}
|
|
240
|
+
onDecision={sendApproval}
|
|
241
|
+
/>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
{/* Worker popup */}
|
|
245
|
+
{selectedWorker && popupAnchor && (() => {
|
|
246
|
+
const session = sessions.find(s => s.sessionId === selectedWorker);
|
|
247
|
+
if (!session) return null;
|
|
248
|
+
return (
|
|
249
|
+
<WorkerPopup
|
|
250
|
+
session={session}
|
|
251
|
+
anchorX={popupAnchor.x}
|
|
252
|
+
anchorY={popupAnchor.y}
|
|
253
|
+
viewportW={viewportSize.w}
|
|
254
|
+
viewportH={viewportSize.h}
|
|
255
|
+
onDismiss={handleDismissPopup}
|
|
256
|
+
onOpenTerminal={session.ptyId ? () => openTerminal(session.ptyId!) : undefined}
|
|
257
|
+
/>
|
|
258
|
+
);
|
|
259
|
+
})()}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{/* Terminal tiles */}
|
|
263
|
+
{visibleTabs.map((tab) => (
|
|
264
|
+
<TerminalTile
|
|
265
|
+
key={tab.ptyId}
|
|
266
|
+
tab={tab}
|
|
267
|
+
session={sessions.find(s => s.ptyId === tab.ptyId)}
|
|
268
|
+
wsRef={wsRef}
|
|
269
|
+
terminalHandlers={terminalHandlersRef}
|
|
270
|
+
onClose={() => closeTerminalTile(tab.ptyId)}
|
|
271
|
+
onSpawnHere={handleSpawn}
|
|
272
|
+
onDragStart={() => handleTileDragStart(tab.ptyId)}
|
|
273
|
+
onDragOver={handleTileDragOver}
|
|
274
|
+
onDrop={() => handleTileDrop(tab.ptyId)}
|
|
275
|
+
/>
|
|
276
|
+
))}
|
|
277
|
+
|
|
278
|
+
{/* Empty tile for spawning */}
|
|
279
|
+
{showEmptyTile && totalTiles > 0 && (
|
|
280
|
+
<EmptyTile
|
|
281
|
+
onSpawn={handleSpawn}
|
|
282
|
+
spawnError={spawnError}
|
|
283
|
+
onDropPty={(ptyId) => {
|
|
284
|
+
setVisiblePtyIds(prev => {
|
|
285
|
+
if (prev.includes(ptyId)) return prev;
|
|
286
|
+
if (prev.length >= MAX_TILES) return prev;
|
|
287
|
+
return [...prev, ptyId];
|
|
288
|
+
});
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
{/* Right panel — sessions */}
|
|
295
|
+
<WorkerPanel
|
|
296
|
+
sessions={sessions}
|
|
297
|
+
visiblePtyIds={visiblePtyIds}
|
|
298
|
+
onSelectWorker={(id) => {
|
|
299
|
+
if (id) {
|
|
300
|
+
setSelectedWorker(null);
|
|
301
|
+
setPopupAnchor(null);
|
|
302
|
+
}
|
|
303
|
+
}}
|
|
304
|
+
onOpenTerminal={openTerminal}
|
|
305
|
+
onSpawn={handleSpawn}
|
|
306
|
+
spawnError={spawnError}
|
|
307
|
+
onDragSessionStart={handleTileDragStart}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|