@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,177 @@
1
+ 'use client';
2
+
3
+ import { useRef, useEffect } from 'react';
4
+ import { Terminal as XTerm } from '@xterm/xterm';
5
+ import { FitAddon } from '@xterm/addon-fit';
6
+ import { WSMessageToClient } from '@/lib/types';
7
+ import '@xterm/xterm/css/xterm.css';
8
+
9
+ interface TerminalProps {
10
+ ptyId: string;
11
+ wsRef: React.RefObject<WebSocket | null>;
12
+ terminalHandlers: React.RefObject<Map<string, (msg: WSMessageToClient) => void>>;
13
+ }
14
+
15
+ export function Terminal({ ptyId, wsRef, terminalHandlers }: TerminalProps) {
16
+ const containerRef = useRef<HTMLDivElement>(null);
17
+ const xtermRef = useRef<XTerm | null>(null);
18
+ const fitAddonRef = useRef<FitAddon | null>(null);
19
+ const subscribedRef = useRef(false);
20
+ const scrollbackReceivedRef = useRef(false);
21
+
22
+ useEffect(() => {
23
+ if (!containerRef.current) return;
24
+
25
+ // Reuse existing terminal if StrictMode remounted us
26
+ if (xtermRef.current) {
27
+ if (!containerRef.current.querySelector('.xterm')) {
28
+ xtermRef.current.open(containerRef.current);
29
+ requestAnimationFrame(() => fitAddonRef.current?.fit());
30
+ }
31
+ } else {
32
+ const term = new XTerm({
33
+ theme: {
34
+ background: '#0e0e1e',
35
+ foreground: '#c8c8d8',
36
+ cursor: '#c8c8d8',
37
+ selectionBackground: '#3a3a6a',
38
+ black: '#0e0e1e',
39
+ red: '#ff5555',
40
+ green: '#50fa7b',
41
+ yellow: '#f1fa8c',
42
+ blue: '#6272a4',
43
+ magenta: '#ff79c6',
44
+ cyan: '#8be9fd',
45
+ white: '#c8c8d8',
46
+ brightBlack: '#555577',
47
+ brightRed: '#ff6e6e',
48
+ brightGreen: '#69ff94',
49
+ brightYellow: '#ffffa5',
50
+ brightBlue: '#d6acff',
51
+ brightMagenta: '#ff92df',
52
+ brightCyan: '#a4ffff',
53
+ brightWhite: '#ffffff',
54
+ },
55
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
56
+ fontSize: 13,
57
+ lineHeight: 1.2,
58
+ cursorBlink: true,
59
+ allowProposedApi: true,
60
+ });
61
+
62
+ const fitAddon = new FitAddon();
63
+ term.loadAddon(fitAddon);
64
+ term.open(containerRef.current);
65
+ requestAnimationFrame(() => fitAddon.fit());
66
+
67
+ xtermRef.current = term;
68
+ fitAddonRef.current = fitAddon;
69
+ }
70
+
71
+ const term = xtermRef.current!;
72
+ const fitAddon = fitAddonRef.current!;
73
+
74
+ // Subscribe to terminal output (only once)
75
+ const ws = wsRef.current;
76
+ const doSubscribe = (socket: WebSocket) => {
77
+ if (subscribedRef.current) return;
78
+ subscribedRef.current = true;
79
+ scrollbackReceivedRef.current = false;
80
+ const dims = fitAddon.proposeDimensions();
81
+ socket.send(JSON.stringify({
82
+ type: 'terminal-subscribe',
83
+ ptyId,
84
+ ...(dims ? { cols: dims.cols, rows: dims.rows } : {}),
85
+ }));
86
+ };
87
+ let onOpen: (() => void) | null = null;
88
+ if (ws) {
89
+ if (ws.readyState === WebSocket.OPEN) {
90
+ doSubscribe(ws);
91
+ } else {
92
+ onOpen = () => doSubscribe(ws);
93
+ ws.addEventListener('open', onOpen, { once: true });
94
+ }
95
+ }
96
+
97
+ // Register handler in the shared map so the hook routes data to us
98
+ terminalHandlers.current.set(ptyId, (msg: WSMessageToClient) => {
99
+ if (msg.type === 'terminal-scrollback') {
100
+ if (scrollbackReceivedRef.current) return;
101
+ scrollbackReceivedRef.current = true;
102
+ term.write(msg.data);
103
+ // Force redraw at correct dimensions after scrollback
104
+ requestAnimationFrame(() => {
105
+ const d = fitAddon.proposeDimensions();
106
+ if (d) {
107
+ const w = wsRef.current;
108
+ if (w && w.readyState === WebSocket.OPEN) {
109
+ w.send(JSON.stringify({ type: 'terminal-resize', ptyId, cols: d.cols, rows: d.rows }));
110
+ }
111
+ }
112
+ });
113
+ } else if (msg.type === 'terminal-output') {
114
+ term.write(msg.data);
115
+ } else if (msg.type === 'terminal-exited') {
116
+ term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
117
+ }
118
+ });
119
+
120
+ // Send user keystrokes to server
121
+ const onDataDisposable = term.onData((data) => {
122
+ const w = wsRef.current;
123
+ if (w && w.readyState === WebSocket.OPEN) {
124
+ w.send(JSON.stringify({ type: 'terminal-input', ptyId, data }));
125
+ }
126
+ });
127
+
128
+ // Handle resize
129
+ let cancelled = false;
130
+ const resizeObserver = new ResizeObserver(() => {
131
+ requestAnimationFrame(() => {
132
+ if (cancelled) return;
133
+ fitAddon.fit();
134
+ const dims = fitAddon.proposeDimensions();
135
+ if (dims) {
136
+ const w = wsRef.current;
137
+ if (w && w.readyState === WebSocket.OPEN) {
138
+ w.send(JSON.stringify({ type: 'terminal-resize', ptyId, cols: dims.cols, rows: dims.rows }));
139
+ }
140
+ }
141
+ });
142
+ });
143
+ resizeObserver.observe(containerRef.current);
144
+
145
+ return () => {
146
+ cancelled = true;
147
+ if (onOpen && ws) ws.removeEventListener('open', onOpen);
148
+ resizeObserver.disconnect();
149
+ onDataDisposable.dispose();
150
+ };
151
+ }, [ptyId, wsRef, terminalHandlers]);
152
+
153
+ // Clean up when ptyId changes or component truly unmounts
154
+ useEffect(() => {
155
+ return () => {
156
+ const ws = wsRef.current;
157
+ if (ws && ws.readyState === WebSocket.OPEN && subscribedRef.current) {
158
+ ws.send(JSON.stringify({ type: 'terminal-unsubscribe', ptyId }));
159
+ }
160
+ subscribedRef.current = false;
161
+ scrollbackReceivedRef.current = false;
162
+ terminalHandlers.current.delete(ptyId);
163
+ xtermRef.current?.dispose();
164
+ xtermRef.current = null;
165
+ fitAddonRef.current = null;
166
+ };
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ }, [ptyId]);
169
+
170
+ return (
171
+ <div
172
+ ref={containerRef}
173
+ className="w-full h-full"
174
+ style={{ backgroundColor: '#0e0e1e' }}
175
+ />
176
+ );
177
+ }
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useCallback, useEffect } from 'react';
4
+ import { Terminal } from './Terminal';
5
+ import { PtyTab } from '@/hooks/usePixelOffice';
6
+ import { Session, WSMessageToClient } from '@/lib/types';
7
+ import { useRecentCwds } from '@/hooks/useRecentCwds';
8
+
9
+ interface TerminalTileProps {
10
+ tab: PtyTab;
11
+ session?: Session;
12
+ wsRef: React.RefObject<WebSocket | null>;
13
+ terminalHandlers: React.RefObject<Map<string, (msg: WSMessageToClient) => void>>;
14
+ onClose: () => void;
15
+ onSpawnHere: (cwd: string) => void;
16
+ /** For drag-to-swap reordering */
17
+ onDragStart?: () => void;
18
+ onDragOver?: (e: React.DragEvent) => void;
19
+ onDrop?: () => void;
20
+ }
21
+
22
+ function projectName(cwd: string): string {
23
+ if (!cwd) return 'starting…';
24
+ return cwd.split('/').filter(Boolean).pop() || cwd;
25
+ }
26
+
27
+ function stateColor(session: Session | undefined, exited: boolean): string {
28
+ if (exited) return '#4a2020';
29
+ if (!session) return '#555';
30
+ switch (session.state) {
31
+ case 'typing': return '#4a7cbf';
32
+ case 'reading': return '#4abf5c';
33
+ case 'waiting': return '#bf8b4a';
34
+ default: return '#555';
35
+ }
36
+ }
37
+
38
+ export function TerminalTile({ tab, session, wsRef, terminalHandlers, onClose, onSpawnHere, onDragStart, onDragOver, onDrop }: TerminalTileProps) {
39
+ const name = projectName(tab.cwd);
40
+ const color = stateColor(session, tab.exited);
41
+ const isActive = session && !tab.exited && session.state !== 'idle';
42
+ const focus = session?.currentFocus || null;
43
+ const lastTool = session?.recentTools?.[session.recentTools.length - 1];
44
+ const subtitle = focus || (lastTool && session?.state !== 'idle' ? lastTool.summary : null);
45
+
46
+ return (
47
+ <div
48
+ className="flex flex-col bg-[#0a0a18] border border-[#1a1a3a] rounded overflow-hidden"
49
+ onDragOver={onDragOver}
50
+ onDrop={onDrop}
51
+ >
52
+ {/* Title bar — draggable for reordering */}
53
+ <div
54
+ className="flex flex-col border-b border-[#1a1a3a] flex-shrink-0 group cursor-grab active:cursor-grabbing"
55
+ draggable
56
+ onDragStart={(e) => {
57
+ e.dataTransfer.effectAllowed = 'move';
58
+ onDragStart?.();
59
+ }}
60
+ >
61
+ <div className="flex items-center h-7 px-2">
62
+ <div
63
+ className={`w-1.5 h-1.5 rounded-full flex-shrink-0 mr-1.5 ${isActive ? 'animate-pulse' : ''}`}
64
+ style={{ backgroundColor: color }}
65
+ />
66
+ <span className="text-[#888] text-[11px] font-mono flex-1 truncate">
67
+ {tab.exited ? `${name} (exited)` : name}
68
+ </span>
69
+ {session?.state === 'waiting' && (
70
+ <span className="text-[#bf8b4a] text-[9px] font-mono mr-2">Approval</span>
71
+ )}
72
+ <button
73
+ className="text-[#222] hover:text-[#888] text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
74
+ onClick={onClose}
75
+ >
76
+
77
+ </button>
78
+ </div>
79
+ {subtitle && (
80
+ <div className="px-2 pb-1 text-[#3a3a5a] text-[10px] font-mono truncate">
81
+ {subtitle}
82
+ </div>
83
+ )}
84
+ </div>
85
+
86
+ {/* Terminal */}
87
+ <div className="flex-1 min-h-0 bg-[#0e0e1e]">
88
+ <Terminal ptyId={tab.ptyId} wsRef={wsRef} terminalHandlers={terminalHandlers} />
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** Empty tile with spawn button — also accepts drops from panel */
95
+ export function EmptyTile({ onSpawn, onDropPty, spawnError }: { onSpawn: (cwd: string) => void; onDropPty?: (ptyId: string) => void; spawnError?: string | null }) {
96
+ const [showInput, setShowInput] = useState(false);
97
+ const [cwd, setCwd] = useState('');
98
+ const inputRef = useRef<HTMLInputElement>(null);
99
+ const { recents } = useRecentCwds();
100
+
101
+ useEffect(() => {
102
+ if (showInput) requestAnimationFrame(() => inputRef.current?.focus());
103
+ }, [showInput]);
104
+
105
+ const handleSubmit = useCallback(() => {
106
+ onSpawn(cwd.trim() || '~');
107
+ setCwd('');
108
+ // Don't close input — it stays open until spawn succeeds (tile gets replaced)
109
+ // or error shows. If spawn succeeds, the tile disappears anyway.
110
+ }, [cwd, onSpawn]);
111
+
112
+ const handleQuickSpawn = useCallback((path: string) => {
113
+ onSpawn(path);
114
+ }, [onSpawn]);
115
+
116
+ return (
117
+ <div
118
+ className="flex flex-col items-center justify-center bg-[#080814] border border-[#141430] border-dashed rounded gap-3"
119
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
120
+ onDrop={(e) => {
121
+ e.preventDefault();
122
+ const ptyId = e.dataTransfer.getData('text/plain');
123
+ if (ptyId) onDropPty?.(ptyId);
124
+ }}
125
+ >
126
+ {showInput ? (
127
+ <div className="flex flex-col gap-2 w-72">
128
+ {/* Recent projects */}
129
+ {recents.length > 0 && (
130
+ <div className="flex flex-wrap gap-1 justify-center">
131
+ {recents.map(r => (
132
+ <button
133
+ key={r}
134
+ onClick={() => handleQuickSpawn(r)}
135
+ className="px-2 py-0.5 bg-[#0a0a16] hover:bg-[#1a1a4a] border border-[#1a1a3a] text-[#556] hover:text-[#aab] text-[10px] font-mono rounded cursor-pointer transition-colors"
136
+ >
137
+ {projectName(r)}
138
+ </button>
139
+ ))}
140
+ </div>
141
+ )}
142
+ <div className="flex gap-1.5">
143
+ <input
144
+ ref={inputRef}
145
+ type="text"
146
+ value={cwd}
147
+ onChange={(e) => setCwd(e.target.value)}
148
+ onKeyDown={(e) => {
149
+ if (e.key === 'Enter') handleSubmit();
150
+ if (e.key === 'Escape') setShowInput(false);
151
+ }}
152
+ placeholder="Desktop/Projects/my-app"
153
+ className="flex-1 bg-[#0a0a16] border border-[#1a1a3a] rounded px-2 py-1.5 text-[11px] font-mono text-[#aab] placeholder-[#2a2a4a] focus:outline-none focus:border-[#3a3a6a]"
154
+ />
155
+ <button
156
+ onClick={handleSubmit}
157
+ className="px-3 py-1.5 bg-[#1a1a4a] hover:bg-[#2a2a6a] text-[#aab] text-[11px] font-mono rounded cursor-pointer"
158
+ >
159
+ Go
160
+ </button>
161
+ </div>
162
+ {spawnError && (
163
+ <div className="text-[#ff5555] text-[10px] font-mono text-center mt-1">{spawnError}</div>
164
+ )}
165
+ </div>
166
+ ) : (
167
+ <>
168
+ {spawnError && (
169
+ <div className="text-[#ff5555] text-[10px] font-mono text-center mb-1">{spawnError}</div>
170
+ )}
171
+ <button
172
+ onClick={() => setShowInput(true)}
173
+ className="px-4 py-2 text-[#2a2a5a] hover:text-[#5a5a8a] text-[12px] font-mono transition-colors cursor-pointer"
174
+ >
175
+ + New Session
176
+ </button>
177
+ </>
178
+ )}
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,261 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Session } from '@/lib/types';
5
+ import { useRecentCwds } from '@/hooks/useRecentCwds';
6
+
7
+ const STATE_COLORS: Record<string, string> = {
8
+ idle: '#444',
9
+ typing: '#4a7cbf',
10
+ reading: '#4abf5c',
11
+ waiting: '#bf8b4a',
12
+ walking: '#8b4abf',
13
+ };
14
+
15
+ function formatDuration(startedAt: number): string {
16
+ const seconds = Math.floor((Date.now() - startedAt) / 1000);
17
+ if (seconds < 60) return `${seconds}s`;
18
+ const minutes = Math.floor(seconds / 60);
19
+ if (minutes < 60) return `${minutes}m`;
20
+ const hours = Math.floor(minutes / 60);
21
+ return `${hours}h ${minutes % 60}m`;
22
+ }
23
+
24
+ function projectName(cwd: string): string {
25
+ return cwd.split('/').filter(Boolean).pop() || cwd;
26
+ }
27
+
28
+ interface Props {
29
+ sessions: Session[];
30
+ visiblePtyIds: string[];
31
+ onSelectWorker?: (sessionId: string | null) => void;
32
+ onOpenTerminal?: (ptyId: string) => void;
33
+ onSpawn?: (cwd: string) => void;
34
+ spawnError?: string | null;
35
+ /** Called when user drags a session from the panel — starts a drag with ptyId */
36
+ onDragSessionStart?: (ptyId: string) => void;
37
+ }
38
+
39
+ export function WorkerPanel({ sessions, visiblePtyIds, onSelectWorker, onOpenTerminal, onSpawn, spawnError, onDragSessionStart }: Props) {
40
+ const [expandedId, setExpandedId] = useState<string | null>(null);
41
+ const [focusing, setFocusing] = useState<string | null>(null);
42
+ const [showSpawnInput, setShowSpawnInput] = useState(false);
43
+ const [spawnCwd, setSpawnCwd] = useState('');
44
+ const { recents: recentCwds } = useRecentCwds();
45
+
46
+ function handleClick(sessionId: string) {
47
+ const next = expandedId === sessionId ? null : sessionId;
48
+ setExpandedId(next);
49
+ onSelectWorker?.(next);
50
+ }
51
+
52
+ async function handleFocusTerminal(sessionId: string) {
53
+ setFocusing(sessionId);
54
+ try {
55
+ await fetch('/api/focus-terminal', {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify({ sessionId }),
59
+ });
60
+ } finally {
61
+ setFocusing(null);
62
+ }
63
+ }
64
+
65
+ function handleSpawnSubmit() {
66
+ const cwd = spawnCwd.trim() || '~';
67
+ onSpawn?.(cwd);
68
+ setSpawnCwd('');
69
+ // Keep open — closes on success (new tab appears) or shows error
70
+ }
71
+
72
+ return (
73
+ <div className="w-56 bg-[#0e0e1e] border-l border-[#1a1a3a] overflow-y-auto flex flex-col">
74
+ {/* Header with spawn button */}
75
+ <div className="px-3 py-2 border-b border-[#1a1a3a] flex items-center justify-between">
76
+ <span className="text-[#444] text-[10px] font-mono uppercase tracking-widest">
77
+ {sessions.length} active
78
+ </span>
79
+ <button
80
+ onClick={() => setShowSpawnInput(!showSpawnInput)}
81
+ className="text-[#333] hover:text-[#888] text-[12px] font-mono cursor-pointer transition-colors"
82
+ title="Spawn new Claude session"
83
+ >
84
+ +
85
+ </button>
86
+ </div>
87
+
88
+ {/* Spawn input + recent projects */}
89
+ {showSpawnInput && (
90
+ <div className="px-2 py-2 border-b border-[#1a1a3a] bg-[#0a0a18]">
91
+ {/* Recent projects */}
92
+ {recentCwds.length > 0 && (
93
+ <div className="flex flex-wrap gap-1 mb-2">
94
+ {recentCwds.map(cwd => (
95
+ <button
96
+ key={cwd}
97
+ onClick={() => { onSpawn?.(cwd); setShowSpawnInput(false); }}
98
+ className="px-1.5 py-0.5 bg-[#0a0a16] hover:bg-[#1a1a4a] border border-[#1a1a3a] text-[#556] hover:text-[#aab] text-[9px] font-mono rounded cursor-pointer transition-colors"
99
+ >
100
+ {projectName(cwd)}
101
+ </button>
102
+ ))}
103
+ </div>
104
+ )}
105
+ <div className="flex gap-1">
106
+ <input
107
+ autoFocus
108
+ type="text"
109
+ value={spawnCwd}
110
+ onChange={(e) => setSpawnCwd(e.target.value)}
111
+ onKeyDown={(e) => {
112
+ if (e.key === 'Enter') handleSpawnSubmit();
113
+ if (e.key === 'Escape') setShowSpawnInput(false);
114
+ }}
115
+ placeholder="~/projects/my-app"
116
+ className="flex-1 bg-[#080816] border border-[#1a1a3a] rounded px-2 py-1 text-[10px] font-mono text-[#aab] placeholder-[#2a2a4a] focus:outline-none focus:border-[#3a3a6a]"
117
+ />
118
+ <button
119
+ onClick={handleSpawnSubmit}
120
+ className="px-2 py-1 bg-[#1a1a4a] hover:bg-[#2a2a6a] text-[#aab] text-[10px] font-mono rounded cursor-pointer"
121
+ >
122
+ Go
123
+ </button>
124
+ </div>
125
+ {spawnError && (
126
+ <div className="text-[#ff5555] text-[9px] font-mono mt-1">{spawnError}</div>
127
+ )}
128
+ </div>
129
+ )}
130
+
131
+ {sessions.length === 0 && (
132
+ <div className="px-3 py-6 text-center">
133
+ <p className="text-[#333] text-[11px] font-mono">No sessions</p>
134
+ </div>
135
+ )}
136
+
137
+ <div className="flex-1 p-1">
138
+ {sessions.map((session) => {
139
+ const isExpanded = expandedId === session.sessionId;
140
+ const isIdle = session.state === 'idle';
141
+ const isWaiting = session.state === 'waiting';
142
+ const project = projectName(session.cwd);
143
+ const stateColor = STATE_COLORS[session.state] || '#444';
144
+ const focus = session.currentFocus || null;
145
+ const lastTool = session.recentTools[session.recentTools.length - 1];
146
+ const isVisible = session.ptyId ? visiblePtyIds.includes(session.ptyId) : false;
147
+
148
+ return (
149
+ <div
150
+ key={session.sessionId}
151
+ className={`rounded cursor-pointer transition-all ${
152
+ isExpanded
153
+ ? 'bg-[#161630] border border-[#2a2a5a]'
154
+ : 'border border-transparent hover:bg-[#12122a]'
155
+ }`}
156
+ onClick={() => handleClick(session.sessionId)}
157
+ // Make PTY sessions draggable from the panel
158
+ draggable={!!session.ptyId}
159
+ onDragStart={(e) => {
160
+ if (session.ptyId) {
161
+ e.dataTransfer.effectAllowed = 'move';
162
+ e.dataTransfer.setData('text/plain', session.ptyId);
163
+ onDragSessionStart?.(session.ptyId);
164
+ }
165
+ }}
166
+ >
167
+ <div className="px-2 py-2">
168
+ {/* Project name + time */}
169
+ <div className="flex items-center gap-1.5">
170
+ <div
171
+ className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${!isIdle && !isWaiting ? 'animate-pulse' : ''}`}
172
+ style={{ backgroundColor: stateColor }}
173
+ />
174
+ <span className="text-[#99a] text-[11px] font-mono font-bold flex-1 truncate">
175
+ {project}
176
+ </span>
177
+ {session.ptyId && (
178
+ <span
179
+ className={`text-[9px] font-mono flex-shrink-0 ${isVisible ? 'text-[#4a7cbf]' : 'text-[#2a2a4a]'}`}
180
+ title={isVisible ? 'Visible in grid' : 'Background — drag to grid'}
181
+ >
182
+ {isVisible ? '◆' : '◇'}
183
+ </span>
184
+ )}
185
+ <span className="text-[#2a2a3a] text-[10px] font-mono flex-shrink-0">
186
+ {formatDuration(session.startedAt)}
187
+ </span>
188
+ </div>
189
+
190
+ {/* Focus */}
191
+ {focus && (
192
+ <div className="text-[#aab0b8] text-[11px] font-mono mt-1 ml-3 leading-relaxed">
193
+ {focus}
194
+ </div>
195
+ )}
196
+
197
+ {lastTool && !isIdle && (
198
+ <div className="text-[#3a3a5a] text-[10px] font-mono mt-0.5 ml-3 truncate">
199
+ {lastTool.summary}
200
+ </div>
201
+ )}
202
+
203
+ {!focus && isIdle && (
204
+ <div className="text-[#2a2a3a] text-[11px] font-mono mt-1 ml-3">Idle</div>
205
+ )}
206
+
207
+ {isWaiting && (
208
+ <div className="ml-3 mt-1">
209
+ <span className="text-[#bf8b4a] text-[10px] font-mono bg-[#bf8b4a]/8 border border-[#bf8b4a]/15 rounded px-1.5 py-0.5 leading-none">
210
+ Approval needed
211
+ </span>
212
+ </div>
213
+ )}
214
+ </div>
215
+
216
+ {isExpanded && (
217
+ <div className="px-2 pb-2 border-t border-[#1a1a3a] pt-1.5">
218
+ {session.recentTools.length > 1 && (
219
+ <div className="mb-1.5">
220
+ {session.recentTools.slice(-4, -1).reverse().map((t, i) => (
221
+ <div
222
+ key={t.timestamp}
223
+ className="text-[10px] font-mono leading-relaxed ml-1 truncate"
224
+ style={{ color: i === 0 ? '#3a3a5a' : '#222238' }}
225
+ >
226
+ {t.summary}
227
+ </div>
228
+ ))}
229
+ </div>
230
+ )}
231
+ {session.ptyId ? (
232
+ <button
233
+ onClick={(e) => {
234
+ e.stopPropagation();
235
+ onOpenTerminal?.(session.ptyId!);
236
+ }}
237
+ className="w-full py-1 bg-[#0a0a16] hover:bg-[#141428] border border-[#1a1a30] text-[#445] hover:text-[#778] text-[10px] font-mono rounded transition-colors cursor-pointer"
238
+ >
239
+ {isVisible ? 'Show in Grid' : 'Open Terminal'}
240
+ </button>
241
+ ) : (
242
+ <button
243
+ onClick={(e) => {
244
+ e.stopPropagation();
245
+ handleFocusTerminal(session.sessionId);
246
+ }}
247
+ disabled={focusing === session.sessionId}
248
+ className="w-full py-1 bg-[#0a0a16] hover:bg-[#141428] border border-[#1a1a30] text-[#445] hover:text-[#778] text-[10px] font-mono rounded transition-colors cursor-pointer disabled:opacity-50"
249
+ >
250
+ {focusing === session.sessionId ? 'Focusing…' : 'Focus Terminal'}
251
+ </button>
252
+ )}
253
+ </div>
254
+ )}
255
+ </div>
256
+ );
257
+ })}
258
+ </div>
259
+ </div>
260
+ );
261
+ }