@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,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
|
+
}
|