@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,219 @@
|
|
|
1
|
+
import { WorkerState } from '@/lib/types';
|
|
2
|
+
import { FRAMES, FRAME_DURATIONS, AnimState } from './sprites';
|
|
3
|
+
import { DESK_POSITIONS, DOOR_X, DOOR_Y, TILE_SIZE, SCALE } from './office-layout';
|
|
4
|
+
|
|
5
|
+
export type FacingDir = 'down' | 'up' | 'right' | 'left';
|
|
6
|
+
|
|
7
|
+
export interface WorkerEntity {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
deskIndex: number;
|
|
10
|
+
charIndex: number; // 0-5, selects which character sheet to use
|
|
11
|
+
// Position in tile coordinates (fractional for smooth movement)
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
targetX: number;
|
|
15
|
+
targetY: number;
|
|
16
|
+
state: AnimState;
|
|
17
|
+
facing: FacingDir;
|
|
18
|
+
frameIndex: number;
|
|
19
|
+
frameTimer: number;
|
|
20
|
+
speechBubble: string | null;
|
|
21
|
+
leaving: boolean;
|
|
22
|
+
arrived: boolean; // has initially arrived at desk
|
|
23
|
+
inBreakRoom: boolean; // currently heading to or at break room
|
|
24
|
+
inMeetingRoom: boolean; // currently heading to or at meeting room (plan mode)
|
|
25
|
+
// Client-side idle detection
|
|
26
|
+
lastToolTime: number; // Date.now() of last active tool call
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WALK_SPEED = 8; // tiles per second
|
|
30
|
+
|
|
31
|
+
// Break room positions workers can go to when idle (cols 14-18, rows 1-6)
|
|
32
|
+
const BREAK_ROOM_SPOTS = [
|
|
33
|
+
{ x: 16, y: 4 }, // in front of coffee table
|
|
34
|
+
{ x: 14, y: 3 }, // left of coffee table
|
|
35
|
+
{ x: 17, y: 3 }, // right of coffee table
|
|
36
|
+
{ x: 15, y: 5 }, // bottom center
|
|
37
|
+
{ x: 16, y: 5 }, // bottom center-right
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Meeting room positions around the table (cols 14-18, rows 8-13)
|
|
41
|
+
const MEETING_ROOM_SPOTS = [
|
|
42
|
+
{ x: 15, y: 9 }, // above table left
|
|
43
|
+
{ x: 16, y: 9 }, // above table right
|
|
44
|
+
{ x: 15, y: 12 }, // below table left
|
|
45
|
+
{ x: 16, y: 12 }, // below table right
|
|
46
|
+
{ x: 14, y: 10 }, // left of table
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export function createWorker(sessionId: string, deskIndex: number): WorkerEntity {
|
|
50
|
+
const desk = DESK_POSITIONS[deskIndex] || DESK_POSITIONS[0];
|
|
51
|
+
return {
|
|
52
|
+
sessionId,
|
|
53
|
+
deskIndex,
|
|
54
|
+
charIndex: deskIndex % 6,
|
|
55
|
+
x: DOOR_X,
|
|
56
|
+
y: DOOR_Y,
|
|
57
|
+
targetX: desk.chairX,
|
|
58
|
+
targetY: desk.chairY,
|
|
59
|
+
state: 'walking',
|
|
60
|
+
facing: 'up',
|
|
61
|
+
frameIndex: 0,
|
|
62
|
+
frameTimer: 0,
|
|
63
|
+
speechBubble: null,
|
|
64
|
+
leaving: false,
|
|
65
|
+
arrived: false,
|
|
66
|
+
inBreakRoom: false,
|
|
67
|
+
inMeetingRoom: false,
|
|
68
|
+
lastToolTime: Date.now(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function updateWorker(worker: WorkerEntity, dt: number): boolean {
|
|
73
|
+
// Move toward target using L-shaped path
|
|
74
|
+
const dx = worker.targetX - worker.x;
|
|
75
|
+
const dy = worker.targetY - worker.y;
|
|
76
|
+
|
|
77
|
+
if (Math.abs(dy) > 0.1 || Math.abs(dx) > 0.1) {
|
|
78
|
+
let moveX = 0;
|
|
79
|
+
let moveY = 0;
|
|
80
|
+
|
|
81
|
+
if (worker.leaving) {
|
|
82
|
+
// Leaving: horizontal first, then vertical
|
|
83
|
+
if (Math.abs(dx) > 0.1) {
|
|
84
|
+
moveX = dx;
|
|
85
|
+
} else {
|
|
86
|
+
moveY = dy;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// Arriving: vertical first, then horizontal
|
|
90
|
+
if (Math.abs(dy) > 0.1) {
|
|
91
|
+
moveY = dy;
|
|
92
|
+
} else {
|
|
93
|
+
moveX = dx;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const moveDist = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
98
|
+
const step = Math.min(WALK_SPEED * dt, moveDist);
|
|
99
|
+
worker.x += (moveX / moveDist) * step;
|
|
100
|
+
worker.y += (moveY / moveDist) * step;
|
|
101
|
+
worker.state = 'walking';
|
|
102
|
+
|
|
103
|
+
// Set facing direction based on movement
|
|
104
|
+
if (Math.abs(moveY) > Math.abs(moveX)) {
|
|
105
|
+
worker.facing = moveY < 0 ? 'up' : 'down';
|
|
106
|
+
} else {
|
|
107
|
+
worker.facing = moveX < 0 ? 'left' : 'right';
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
worker.x = worker.targetX;
|
|
111
|
+
worker.y = worker.targetY;
|
|
112
|
+
if (worker.leaving && worker.targetX === DOOR_X && worker.targetY === DOOR_Y) {
|
|
113
|
+
return true; // signal removal
|
|
114
|
+
}
|
|
115
|
+
if (!worker.arrived) {
|
|
116
|
+
worker.arrived = true;
|
|
117
|
+
worker.state = 'idle';
|
|
118
|
+
worker.facing = 'up'; // face the desk/screen
|
|
119
|
+
} else if (worker.state === 'walking') {
|
|
120
|
+
// Reached a new target after initial arrival (break room trip or returning)
|
|
121
|
+
if (worker.inBreakRoom) {
|
|
122
|
+
worker.state = 'idle';
|
|
123
|
+
worker.facing = 'down'; // relax, face the viewer
|
|
124
|
+
} else {
|
|
125
|
+
worker.state = 'idle';
|
|
126
|
+
worker.facing = 'up'; // back at desk, face the screen
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Advance animation frame
|
|
132
|
+
const duration = FRAME_DURATIONS[worker.state];
|
|
133
|
+
worker.frameTimer += dt * 1000;
|
|
134
|
+
if (worker.frameTimer >= duration) {
|
|
135
|
+
worker.frameTimer -= duration;
|
|
136
|
+
const frames = FRAMES[worker.state];
|
|
137
|
+
worker.frameIndex = (worker.frameIndex + 1) % frames.length;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function setWorkerState(worker: WorkerEntity, state: WorkerState) {
|
|
144
|
+
if (worker.leaving) return;
|
|
145
|
+
|
|
146
|
+
if (worker.arrived) {
|
|
147
|
+
// In meeting room: stay there regardless of state changes (plan mode controls exit)
|
|
148
|
+
if (worker.inMeetingRoom) {
|
|
149
|
+
const animState: AnimState = state === 'walking' ? 'walking' : state;
|
|
150
|
+
if (animState !== worker.state && worker.state !== 'walking') {
|
|
151
|
+
worker.state = animState;
|
|
152
|
+
worker.frameIndex = 0;
|
|
153
|
+
worker.frameTimer = 0;
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (state === 'idle' && !worker.inBreakRoom) {
|
|
159
|
+
// Go to break room — pick a spot based on desk index
|
|
160
|
+
const spot = BREAK_ROOM_SPOTS[worker.deskIndex % BREAK_ROOM_SPOTS.length];
|
|
161
|
+
worker.targetX = spot.x;
|
|
162
|
+
worker.targetY = spot.y;
|
|
163
|
+
worker.inBreakRoom = true;
|
|
164
|
+
// Walking state will be set by updateWorker when dx/dy detected
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (state !== 'idle' && worker.inBreakRoom) {
|
|
169
|
+
// Back to work — return to desk
|
|
170
|
+
const desk = DESK_POSITIONS[worker.deskIndex] || DESK_POSITIONS[0];
|
|
171
|
+
worker.targetX = desk.chairX;
|
|
172
|
+
worker.targetY = desk.chairY;
|
|
173
|
+
worker.inBreakRoom = false;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Normal state change at desk
|
|
178
|
+
const animState: AnimState = state === 'walking' ? 'walking' : state;
|
|
179
|
+
if (animState !== worker.state) {
|
|
180
|
+
worker.state = animState;
|
|
181
|
+
worker.frameIndex = 0;
|
|
182
|
+
worker.frameTimer = 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function setWorkerPlanMode(worker: WorkerEntity, inPlanMode: boolean) {
|
|
188
|
+
if (worker.leaving) return;
|
|
189
|
+
if (!worker.arrived) return;
|
|
190
|
+
|
|
191
|
+
if (inPlanMode && !worker.inMeetingRoom) {
|
|
192
|
+
// Move to meeting room
|
|
193
|
+
const spot = MEETING_ROOM_SPOTS[worker.deskIndex % MEETING_ROOM_SPOTS.length];
|
|
194
|
+
worker.targetX = spot.x;
|
|
195
|
+
worker.targetY = spot.y;
|
|
196
|
+
worker.inMeetingRoom = true;
|
|
197
|
+
worker.inBreakRoom = false;
|
|
198
|
+
} else if (!inPlanMode && worker.inMeetingRoom) {
|
|
199
|
+
// Return to desk
|
|
200
|
+
const desk = DESK_POSITIONS[worker.deskIndex] || DESK_POSITIONS[0];
|
|
201
|
+
worker.targetX = desk.chairX;
|
|
202
|
+
worker.targetY = desk.chairY;
|
|
203
|
+
worker.inMeetingRoom = false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function startLeaving(worker: WorkerEntity) {
|
|
208
|
+
worker.leaving = true;
|
|
209
|
+
worker.targetX = DOOR_X;
|
|
210
|
+
worker.targetY = DOOR_Y;
|
|
211
|
+
worker.speechBubble = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getWorkerScreenPos(worker: WorkerEntity): { x: number; y: number } {
|
|
215
|
+
return {
|
|
216
|
+
x: worker.x * TILE_SIZE * SCALE,
|
|
217
|
+
y: worker.y * TILE_SIZE * SCALE,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useCallback, useState } from 'react';
|
|
4
|
+
import { Session, WSMessageToClient } from '@/lib/types';
|
|
5
|
+
import { buildGrid } from '@/game/office-layout';
|
|
6
|
+
import { renderOffice, initRenderer } from '@/game/renderer';
|
|
7
|
+
import { WorkerEntity, createWorker, updateWorker, setWorkerState, setWorkerPlanMode, startLeaving } from '@/game/worker-entity';
|
|
8
|
+
import { loadAssets, AssetBundle } from '@/game/asset-loader';
|
|
9
|
+
|
|
10
|
+
/** Get the speech bubble text from the session's most recent tool summary. Truncates to ~25 chars at word boundary. */
|
|
11
|
+
function bubbleText(session: Session): string | null {
|
|
12
|
+
const last = session.recentTools[session.recentTools.length - 1];
|
|
13
|
+
if (!last) return null;
|
|
14
|
+
const s = last.summary;
|
|
15
|
+
if (s.length <= 25) return s;
|
|
16
|
+
// Cut at last space before limit to avoid mid-word truncation
|
|
17
|
+
const cut = s.lastIndexOf(' ', 23);
|
|
18
|
+
return (cut > 10 ? s.slice(0, cut) : s.slice(0, 23)) + '…';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** For approval-request bubbles, phrase it as a question like "Can I push?" */
|
|
22
|
+
function approvalLabel(toolName: string, toolInput: Record<string, unknown>): string {
|
|
23
|
+
if (toolName === 'Bash') {
|
|
24
|
+
const cmd = String(toolInput.command || '').trim();
|
|
25
|
+
// Extract the first "word" of the command for a short question
|
|
26
|
+
const firstWord = cmd.split(/\s+/)[0] || 'run';
|
|
27
|
+
const short = firstWord.length > 10 ? firstWord.slice(0, 8) + '…' : firstWord;
|
|
28
|
+
return `Can I ${short}?`;
|
|
29
|
+
}
|
|
30
|
+
if (toolName === 'Edit' || toolName === 'Write' || toolName === 'MultiEdit') return 'Can I edit?';
|
|
31
|
+
if (toolName === 'Read') return 'Can I read?';
|
|
32
|
+
|
|
33
|
+
const mcpMatch = toolName.match(/^mcp__([^_]+(?:_[^_]+)*)__(.+)$/);
|
|
34
|
+
if (mcpMatch) {
|
|
35
|
+
const server = mcpMatch[1].toLowerCase();
|
|
36
|
+
if (server.includes('chrome') || server.includes('browser') || server.includes('playwright')) return 'Browser?';
|
|
37
|
+
if (server.includes('clickup')) return 'ClickUp?';
|
|
38
|
+
const cleaned = server.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
39
|
+
return (cleaned.length > 12 ? cleaned.slice(0, 10) + '…' : cleaned) + '?';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const label = toolName.length > 15 ? toolName.slice(0, 13) + '…' : toolName;
|
|
43
|
+
return `${label}?`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ApprovalRequest {
|
|
47
|
+
id: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
toolName: string;
|
|
50
|
+
toolInput: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PtyTab {
|
|
54
|
+
ptyId: string;
|
|
55
|
+
cwd: string;
|
|
56
|
+
exited: boolean;
|
|
57
|
+
exitCode?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function usePixelOffice(canvasRef: React.RefObject<HTMLCanvasElement | null>) {
|
|
61
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
62
|
+
const workersRef = useRef<Map<string, WorkerEntity>>(new Map());
|
|
63
|
+
const gridRef = useRef(buildGrid());
|
|
64
|
+
const animFrameRef = useRef<number>(0);
|
|
65
|
+
const lastTimeRef = useRef<number>(0);
|
|
66
|
+
const assetsRef = useRef<AssetBundle | null>(null);
|
|
67
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
68
|
+
const [approvals, setApprovals] = useState<ApprovalRequest[]>([]);
|
|
69
|
+
const [assetsLoaded, setAssetsLoaded] = useState(false);
|
|
70
|
+
const [selectedWorker, setSelectedWorker] = useState<string | null>(null);
|
|
71
|
+
const [ptyTabs, setPtyTabs] = useState<PtyTab[]>([]);
|
|
72
|
+
const [spawnError, setSpawnError] = useState<string | null>(null);
|
|
73
|
+
/** Map of ptyId → data handler, so multiple terminals can receive data simultaneously */
|
|
74
|
+
const terminalHandlersRef = useRef<Map<string, (msg: WSMessageToClient) => void>>(new Map());
|
|
75
|
+
const exitTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
|
|
76
|
+
const initialSyncDoneRef = useRef(false);
|
|
77
|
+
/** Called on successful spawn — used by UI to save recents only on success */
|
|
78
|
+
const onSpawnSuccessRef = useRef<((ptyId: string) => void) | null>(null);
|
|
79
|
+
|
|
80
|
+
const sendApproval = useCallback((approvalId: string, decision: 'allow' | 'deny') => {
|
|
81
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
82
|
+
wsRef.current.send(JSON.stringify({
|
|
83
|
+
type: 'approval-response',
|
|
84
|
+
approvalId,
|
|
85
|
+
decision,
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
setApprovals(prev => prev.filter(a => a.id !== approvalId));
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const spawnSession = useCallback((cwd: string) => {
|
|
92
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
93
|
+
wsRef.current.send(JSON.stringify({
|
|
94
|
+
type: 'spawn-session',
|
|
95
|
+
cwd,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
/** Update existing ptyTab cwd from session data. Never creates tabs —
|
|
101
|
+
* creation only happens via spawn-result or initial sessions snapshot on connect. */
|
|
102
|
+
const syncPtyTabRef = useRef((session: Session) => {
|
|
103
|
+
if (!session.ptyId) return;
|
|
104
|
+
setPtyTabs(prev => {
|
|
105
|
+
const idx = prev.findIndex(t => t.ptyId === session.ptyId);
|
|
106
|
+
if (idx < 0) return prev; // don't auto-create — user may have closed it
|
|
107
|
+
if (prev[idx].cwd === session.cwd) return prev;
|
|
108
|
+
const next = [...prev];
|
|
109
|
+
next[idx] = { ...next[idx], cwd: session.cwd };
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Load assets on mount
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
loadAssets().then(bundle => {
|
|
117
|
+
assetsRef.current = bundle;
|
|
118
|
+
initRenderer(bundle);
|
|
119
|
+
setAssetsLoaded(true);
|
|
120
|
+
}).catch(err => {
|
|
121
|
+
console.error('[Assets] Failed to load:', err);
|
|
122
|
+
});
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
// WebSocket connection
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
128
|
+
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
129
|
+
wsRef.current = ws;
|
|
130
|
+
|
|
131
|
+
const handleMessage = (event: MessageEvent) => {
|
|
132
|
+
let msg: WSMessageToClient;
|
|
133
|
+
try {
|
|
134
|
+
msg = JSON.parse(event.data);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('[WS] Failed to parse message:', err);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
switch (msg.type) {
|
|
140
|
+
case 'sessions': {
|
|
141
|
+
setSessions(msg.sessions);
|
|
142
|
+
const workers = workersRef.current;
|
|
143
|
+
for (const s of msg.sessions) {
|
|
144
|
+
if (!workers.has(s.sessionId)) {
|
|
145
|
+
workers.set(s.sessionId, createWorker(s.sessionId, s.deskIndex));
|
|
146
|
+
}
|
|
147
|
+
if (!initialSyncDoneRef.current && s.ptyId) {
|
|
148
|
+
// First sessions message (page load) — restore tabs for live embedded PTYs
|
|
149
|
+
setPtyTabs(prev => prev.some(t => t.ptyId === s.ptyId) ? prev : [...prev, { ptyId: s.ptyId!, cwd: s.cwd, exited: false }]);
|
|
150
|
+
} else {
|
|
151
|
+
syncPtyTabRef.current(s); // subsequent messages — only update cwd
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
initialSyncDoneRef.current = true;
|
|
155
|
+
const sessionIds = new Set(msg.sessions.map(s => s.sessionId));
|
|
156
|
+
for (const [id, worker] of workers) {
|
|
157
|
+
if (!sessionIds.has(id) && !worker.leaving) {
|
|
158
|
+
startLeaving(worker);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case 'session-update': {
|
|
164
|
+
const workers = workersRef.current;
|
|
165
|
+
const s = msg.session;
|
|
166
|
+
syncPtyTabRef.current(s);
|
|
167
|
+
setSessions(prev => {
|
|
168
|
+
const idx = prev.findIndex(p => p.sessionId === s.sessionId);
|
|
169
|
+
if (idx >= 0) {
|
|
170
|
+
const next = [...prev];
|
|
171
|
+
next[idx] = s;
|
|
172
|
+
return next;
|
|
173
|
+
}
|
|
174
|
+
return [...prev, s];
|
|
175
|
+
});
|
|
176
|
+
if (!workers.has(s.sessionId)) {
|
|
177
|
+
workers.set(s.sessionId, createWorker(s.sessionId, s.deskIndex));
|
|
178
|
+
}
|
|
179
|
+
const worker = workers.get(s.sessionId)!;
|
|
180
|
+
setWorkerState(worker, s.state);
|
|
181
|
+
// Handle plan mode
|
|
182
|
+
setWorkerPlanMode(worker, !!s.inPlanMode);
|
|
183
|
+
// Speech bubbles — focus title is hero, tool summary is fallback
|
|
184
|
+
if (s.state === 'waiting') {
|
|
185
|
+
worker.speechBubble = approvalLabel(s.currentTool || 'Unknown', {});
|
|
186
|
+
} else if (s.state === 'typing' || s.state === 'reading') {
|
|
187
|
+
// Focus (intent) is primary, tool summary is fallback
|
|
188
|
+
const text = s.currentFocus || bubbleText(s);
|
|
189
|
+
if (text) {
|
|
190
|
+
if (text.length <= 22) {
|
|
191
|
+
worker.speechBubble = text;
|
|
192
|
+
} else {
|
|
193
|
+
const cut = text.lastIndexOf(' ', 20);
|
|
194
|
+
worker.speechBubble = (cut > 8 ? text.slice(0, cut) : text.slice(0, 20)) + '…';
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
worker.speechBubble = null;
|
|
198
|
+
}
|
|
199
|
+
} else if (s.state === 'idle') {
|
|
200
|
+
worker.speechBubble = null;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case 'session-remove': {
|
|
205
|
+
setSessions(prev => prev.filter(s => s.sessionId !== msg.sessionId));
|
|
206
|
+
const worker = workersRef.current.get(msg.sessionId);
|
|
207
|
+
if (worker) startLeaving(worker);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case 'approval-request': {
|
|
211
|
+
setApprovals(prev => [...prev, msg.approval]);
|
|
212
|
+
const worker = workersRef.current.get(msg.approval.sessionId);
|
|
213
|
+
if (worker) worker.speechBubble = approvalLabel(msg.approval.toolName, msg.approval.toolInput);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case 'approval-resolved': {
|
|
217
|
+
setApprovals(prev => prev.filter(a => a.id !== msg.approvalId));
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'notification': {
|
|
221
|
+
console.log(`[Notification] ${msg.sessionId}: ${msg.message}`);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'spawn-result': {
|
|
225
|
+
if (msg.success && msg.ptyId) {
|
|
226
|
+
setPtyTabs(prev => prev.some(t => t.ptyId === msg.ptyId) ? prev : [...prev, { ptyId: msg.ptyId, cwd: '', exited: false }]);
|
|
227
|
+
setSpawnError(null);
|
|
228
|
+
onSpawnSuccessRef.current?.(msg.ptyId);
|
|
229
|
+
} else {
|
|
230
|
+
const errMsg = msg.error || 'Spawn failed';
|
|
231
|
+
setSpawnError(errMsg);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case 'terminal-exited': {
|
|
236
|
+
terminalHandlersRef.current.get(msg.ptyId)?.(msg);
|
|
237
|
+
// Remove the tab — ghost tabs (exitCode -1) immediately, normal exits after 2s
|
|
238
|
+
const delay = msg.exitCode === -1 ? 0 : 2000;
|
|
239
|
+
const timerId = setTimeout(() => {
|
|
240
|
+
exitTimersRef.current.delete(timerId);
|
|
241
|
+
setPtyTabs(prev => prev.filter(t => t.ptyId !== msg.ptyId));
|
|
242
|
+
}, delay);
|
|
243
|
+
exitTimersRef.current.add(timerId);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'terminal-output':
|
|
247
|
+
case 'terminal-scrollback': {
|
|
248
|
+
// Forward to the specific terminal's handler
|
|
249
|
+
const handler = terminalHandlersRef.current.get(msg.ptyId);
|
|
250
|
+
handler?.(msg);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
ws.addEventListener('message', handleMessage);
|
|
257
|
+
ws.onclose = () => console.log('[WS] Disconnected');
|
|
258
|
+
ws.onerror = (err) => console.error('[WS] Error:', err);
|
|
259
|
+
|
|
260
|
+
return () => {
|
|
261
|
+
ws.removeEventListener('message', handleMessage);
|
|
262
|
+
ws.close();
|
|
263
|
+
for (const id of exitTimersRef.current) clearTimeout(id);
|
|
264
|
+
exitTimersRef.current.clear();
|
|
265
|
+
};
|
|
266
|
+
}, []);
|
|
267
|
+
|
|
268
|
+
// Game loop — starts only after assets are loaded
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (!assetsLoaded) return;
|
|
271
|
+
|
|
272
|
+
const canvas = canvasRef.current;
|
|
273
|
+
if (!canvas) return;
|
|
274
|
+
const ctx = canvas.getContext('2d');
|
|
275
|
+
if (!ctx) return;
|
|
276
|
+
|
|
277
|
+
const loop = (time: number) => {
|
|
278
|
+
const dt = lastTimeRef.current ? (time - lastTimeRef.current) / 1000 : 0.016;
|
|
279
|
+
lastTimeRef.current = time;
|
|
280
|
+
|
|
281
|
+
// Update workers
|
|
282
|
+
const toRemove: string[] = [];
|
|
283
|
+
for (const [id, worker] of workersRef.current) {
|
|
284
|
+
const done = updateWorker(worker, dt);
|
|
285
|
+
if (done) toRemove.push(id);
|
|
286
|
+
}
|
|
287
|
+
for (const id of toRemove) {
|
|
288
|
+
workersRef.current.delete(id);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Render
|
|
292
|
+
renderOffice(ctx, gridRef.current, [...workersRef.current.values()], dt);
|
|
293
|
+
|
|
294
|
+
animFrameRef.current = requestAnimationFrame(loop);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
animFrameRef.current = requestAnimationFrame(loop);
|
|
298
|
+
|
|
299
|
+
return () => { cancelAnimationFrame(animFrameRef.current); };
|
|
300
|
+
}, [assetsLoaded, canvasRef]);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
sessions,
|
|
304
|
+
approvals,
|
|
305
|
+
sendApproval,
|
|
306
|
+
assetsLoaded,
|
|
307
|
+
selectedWorker,
|
|
308
|
+
setSelectedWorker,
|
|
309
|
+
workersRef,
|
|
310
|
+
wsRef,
|
|
311
|
+
ptyTabs,
|
|
312
|
+
setPtyTabs,
|
|
313
|
+
spawnSession,
|
|
314
|
+
spawnError,
|
|
315
|
+
terminalHandlersRef,
|
|
316
|
+
onSpawnSuccessRef,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = 'pixel-office-recent-cwds';
|
|
6
|
+
const MAX_RECENTS = 10;
|
|
7
|
+
|
|
8
|
+
export function useRecentCwds() {
|
|
9
|
+
const [recents, setRecents] = useState<string[]>([]);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
try {
|
|
13
|
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
|
14
|
+
if (Array.isArray(stored)) setRecents(stored);
|
|
15
|
+
} catch { /* ignore */ }
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
const saveRecent = useCallback((cwd: string) => {
|
|
19
|
+
setRecents(prev => {
|
|
20
|
+
const updated = [cwd, ...prev.filter(c => c !== cwd)].slice(0, MAX_RECENTS);
|
|
21
|
+
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); } catch {}
|
|
22
|
+
return updated;
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return { recents, saveRecent };
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { PendingApproval } from './types';
|
|
2
|
+
|
|
3
|
+
// Use globalThis so the same pending Map is shared across
|
|
4
|
+
// Next.js App Router module instances and the custom server.ts
|
|
5
|
+
declare global {
|
|
6
|
+
// eslint-disable-next-line no-var
|
|
7
|
+
var __pending: Map<string, PendingApproval> | undefined;
|
|
8
|
+
// eslint-disable-next-line no-var
|
|
9
|
+
var __approvalIdCounter: number | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getPending(): Map<string, PendingApproval> {
|
|
13
|
+
if (!globalThis.__pending) {
|
|
14
|
+
globalThis.__pending = new Map();
|
|
15
|
+
}
|
|
16
|
+
return globalThis.__pending;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function nextId(): number {
|
|
20
|
+
if (globalThis.__approvalIdCounter === undefined) {
|
|
21
|
+
globalThis.__approvalIdCounter = 0;
|
|
22
|
+
}
|
|
23
|
+
return ++globalThis.__approvalIdCounter;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createApproval(
|
|
27
|
+
sessionId: string,
|
|
28
|
+
toolName: string,
|
|
29
|
+
toolInput: Record<string, unknown>
|
|
30
|
+
): { approval: PendingApproval; promise: Promise<'allow' | 'deny'> } {
|
|
31
|
+
const pending = getPending();
|
|
32
|
+
const id = `approval-${nextId()}-${Date.now()}`;
|
|
33
|
+
|
|
34
|
+
let resolveRef!: (decision: 'allow' | 'deny') => void;
|
|
35
|
+
const promise = new Promise<'allow' | 'deny'>((resolve) => {
|
|
36
|
+
resolveRef = resolve;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const approval: PendingApproval = {
|
|
40
|
+
id,
|
|
41
|
+
sessionId,
|
|
42
|
+
toolName,
|
|
43
|
+
toolInput,
|
|
44
|
+
createdAt: Date.now(),
|
|
45
|
+
resolve: resolveRef,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
pending.set(id, approval);
|
|
49
|
+
|
|
50
|
+
// No timeout — approval stays open until the boss decides.
|
|
51
|
+
// If no browser is connected, the pre-tool-use route handles fallthrough.
|
|
52
|
+
|
|
53
|
+
return { approval, promise };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveApproval(id: string, decision: 'allow' | 'deny'): boolean {
|
|
57
|
+
const pending = getPending();
|
|
58
|
+
const entry = pending.get(id);
|
|
59
|
+
if (!entry) return false;
|
|
60
|
+
pending.delete(id);
|
|
61
|
+
entry.resolve(decision);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getPendingApprovals(): PendingApproval[] {
|
|
66
|
+
return [...getPending().values()];
|
|
67
|
+
}
|