@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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/bin.sh +16 -0
  4. package/bin.ts +162 -0
  5. package/next-env.d.ts +6 -0
  6. package/next.config.ts +7 -0
  7. package/package.json +51 -0
  8. package/postcss.config.mjs +7 -0
  9. package/public/assets/characters/char_0.png +0 -0
  10. package/public/assets/characters/char_1.png +0 -0
  11. package/public/assets/characters/char_2.png +0 -0
  12. package/public/assets/characters/char_3.png +0 -0
  13. package/public/assets/characters/char_4.png +0 -0
  14. package/public/assets/characters/char_5.png +0 -0
  15. package/public/assets/characters.png +0 -0
  16. package/public/assets/default-layout-1.json +92 -0
  17. package/public/assets/floors/floor_0.png +0 -0
  18. package/public/assets/floors/floor_1.png +0 -0
  19. package/public/assets/floors/floor_2.png +0 -0
  20. package/public/assets/floors/floor_3.png +0 -0
  21. package/public/assets/floors/floor_4.png +0 -0
  22. package/public/assets/floors/floor_5.png +0 -0
  23. package/public/assets/floors/floor_6.png +0 -0
  24. package/public/assets/floors/floor_7.png +0 -0
  25. package/public/assets/floors/floor_8.png +0 -0
  26. package/public/assets/furniture/BIN/BIN.png +0 -0
  27. package/public/assets/furniture/BIN/manifest.json +13 -0
  28. package/public/assets/furniture/BOOKSHELF/BOOKSHELF.png +0 -0
  29. package/public/assets/furniture/BOOKSHELF/manifest.json +13 -0
  30. package/public/assets/furniture/CACTUS/CACTUS.png +0 -0
  31. package/public/assets/furniture/CACTUS/manifest.json +13 -0
  32. package/public/assets/furniture/CLOCK/CLOCK.png +0 -0
  33. package/public/assets/furniture/CLOCK/manifest.json +13 -0
  34. package/public/assets/furniture/COFFEE/COFFEE.png +0 -0
  35. package/public/assets/furniture/COFFEE/manifest.json +13 -0
  36. package/public/assets/furniture/COFFEE_TABLE/COFFEE_TABLE.png +0 -0
  37. package/public/assets/furniture/COFFEE_TABLE/manifest.json +13 -0
  38. package/public/assets/furniture/CUSHIONED_BENCH/CUSHIONED_BENCH.png +0 -0
  39. package/public/assets/furniture/CUSHIONED_BENCH/manifest.json +13 -0
  40. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_BACK.png +0 -0
  41. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_FRONT.png +0 -0
  42. package/public/assets/furniture/CUSHIONED_CHAIR/CUSHIONED_CHAIR_SIDE.png +0 -0
  43. package/public/assets/furniture/CUSHIONED_CHAIR/manifest.json +44 -0
  44. package/public/assets/furniture/DESK/DESK_FRONT.png +0 -0
  45. package/public/assets/furniture/DESK/DESK_SIDE.png +0 -0
  46. package/public/assets/furniture/DESK/manifest.json +33 -0
  47. package/public/assets/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png +0 -0
  48. package/public/assets/furniture/DOUBLE_BOOKSHELF/manifest.json +13 -0
  49. package/public/assets/furniture/HANGING_PLANT/HANGING_PLANT.png +0 -0
  50. package/public/assets/furniture/HANGING_PLANT/manifest.json +13 -0
  51. package/public/assets/furniture/LARGE_PAINTING/LARGE_PAINTING.png +0 -0
  52. package/public/assets/furniture/LARGE_PAINTING/manifest.json +13 -0
  53. package/public/assets/furniture/LARGE_PLANT/LARGE_PLANT.png +0 -0
  54. package/public/assets/furniture/LARGE_PLANT/manifest.json +13 -0
  55. package/public/assets/furniture/PC/PC_BACK.png +0 -0
  56. package/public/assets/furniture/PC/PC_FRONT_OFF.png +0 -0
  57. package/public/assets/furniture/PC/PC_FRONT_ON_1.png +0 -0
  58. package/public/assets/furniture/PC/PC_FRONT_ON_2.png +0 -0
  59. package/public/assets/furniture/PC/PC_FRONT_ON_3.png +0 -0
  60. package/public/assets/furniture/PC/PC_SIDE.png +0 -0
  61. package/public/assets/furniture/PC/manifest.json +88 -0
  62. package/public/assets/furniture/PLANT/PLANT.png +0 -0
  63. package/public/assets/furniture/PLANT/manifest.json +13 -0
  64. package/public/assets/furniture/PLANT_2/PLANT_2.png +0 -0
  65. package/public/assets/furniture/PLANT_2/manifest.json +13 -0
  66. package/public/assets/furniture/POT/POT.png +0 -0
  67. package/public/assets/furniture/POT/manifest.json +13 -0
  68. package/public/assets/furniture/SMALL_PAINTING/SMALL_PAINTING.png +0 -0
  69. package/public/assets/furniture/SMALL_PAINTING/manifest.json +13 -0
  70. package/public/assets/furniture/SMALL_PAINTING_2/SMALL_PAINTING_2.png +0 -0
  71. package/public/assets/furniture/SMALL_PAINTING_2/manifest.json +13 -0
  72. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_FRONT.png +0 -0
  73. package/public/assets/furniture/SMALL_TABLE/SMALL_TABLE_SIDE.png +0 -0
  74. package/public/assets/furniture/SMALL_TABLE/manifest.json +33 -0
  75. package/public/assets/furniture/SOFA/SOFA_BACK.png +0 -0
  76. package/public/assets/furniture/SOFA/SOFA_FRONT.png +0 -0
  77. package/public/assets/furniture/SOFA/SOFA_SIDE.png +0 -0
  78. package/public/assets/furniture/SOFA/manifest.json +44 -0
  79. package/public/assets/furniture/TABLE_FRONT/TABLE_FRONT.png +0 -0
  80. package/public/assets/furniture/TABLE_FRONT/manifest.json +13 -0
  81. package/public/assets/furniture/WHITEBOARD/WHITEBOARD.png +0 -0
  82. package/public/assets/furniture/WHITEBOARD/manifest.json +13 -0
  83. package/public/assets/furniture/WOODEN_BENCH/WOODEN_BENCH.png +0 -0
  84. package/public/assets/furniture/WOODEN_BENCH/manifest.json +13 -0
  85. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_BACK.png +0 -0
  86. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png +0 -0
  87. package/public/assets/furniture/WOODEN_CHAIR/WOODEN_CHAIR_SIDE.png +0 -0
  88. package/public/assets/furniture/WOODEN_CHAIR/manifest.json +44 -0
  89. package/public/assets/walls/wall_0.png +0 -0
  90. package/scripts/setup.ts +158 -0
  91. package/server.ts +53 -0
  92. package/src/app/api/focus-terminal/route.ts +65 -0
  93. package/src/app/api/hooks/notification/route.ts +19 -0
  94. package/src/app/api/hooks/post-tool-use/route.ts +26 -0
  95. package/src/app/api/hooks/pre-tool-use/route.ts +189 -0
  96. package/src/app/api/hooks/session-end/route.ts +31 -0
  97. package/src/app/api/hooks/session-start/route.ts +47 -0
  98. package/src/app/api/hooks/stop/route.ts +19 -0
  99. package/src/app/api/hooks/user-prompt/route.ts +92 -0
  100. package/src/app/favicon.ico +0 -0
  101. package/src/app/globals.css +14 -0
  102. package/src/app/layout.tsx +21 -0
  103. package/src/app/page.tsx +5 -0
  104. package/src/components/ApprovalToast.tsx +132 -0
  105. package/src/components/OfficeCanvas.tsx +311 -0
  106. package/src/components/Terminal.tsx +177 -0
  107. package/src/components/TerminalTile.tsx +181 -0
  108. package/src/components/WorkerPanel.tsx +261 -0
  109. package/src/components/WorkerPopup.tsx +116 -0
  110. package/src/game/asset-loader.ts +172 -0
  111. package/src/game/office-layout.ts +287 -0
  112. package/src/game/renderer.ts +369 -0
  113. package/src/game/sprites.ts +133 -0
  114. package/src/game/worker-entity.ts +219 -0
  115. package/src/hooks/usePixelOffice.ts +318 -0
  116. package/src/hooks/useRecentCwds.ts +27 -0
  117. package/src/lib/approval-queue.ts +67 -0
  118. package/src/lib/pty-manager.ts +267 -0
  119. package/src/lib/store.ts +181 -0
  120. package/src/lib/tool-classifier.ts +224 -0
  121. package/src/lib/transcript.ts +109 -0
  122. package/src/lib/types.ts +58 -0
  123. package/src/lib/ws-server.ts +270 -0
  124. 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
+ }