@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,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,14 @@
1
+ @import "tailwindcss";
2
+
3
+ body {
4
+ margin: 0;
5
+ padding: 0;
6
+ background: #1a1a2e;
7
+ color: #ededed;
8
+ font-family: monospace;
9
+ }
10
+
11
+ canvas {
12
+ image-rendering: pixelated;
13
+ image-rendering: crisp-edges;
14
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import { OfficeCanvas } from '@/components/OfficeCanvas';
2
+
3
+ export default function Home() {
4
+ return <OfficeCanvas />;
5
+ }
@@ -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
+ }