@volter/editor-game 0.5.67 → 0.5.68

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.
@@ -25,8 +25,11 @@
25
25
  */
26
26
 
27
27
  import { editorHost } from '@volter/editor-sdk/host';
28
+ import { NETWORK_AUTOSTART_SECTION } from '../services/game-network';
28
29
  import {
29
30
  Button,
31
+ Checkbox,
32
+ TextArea,
30
33
  DisclosureIcon,
31
34
  fontSizeVar,
32
35
  NumberInput,
@@ -37,6 +40,7 @@ import {
37
40
  import type {
38
41
  ConnectionState,
39
42
  NetConditioning,
43
+ NetEntityTraffic,
40
44
  NetMessageEvent,
41
45
  NetServerInspection,
42
46
  NetTypeTraffic,
@@ -574,6 +578,9 @@ function NetworkHeader({
574
578
  );
575
579
  }
576
580
 
581
+ const AUTOSTART_SECTION = NETWORK_AUTOSTART_SECTION;
582
+ const DRAFT_SECTION = 'networkMessageDraft';
583
+
577
584
  // --- The panel ------------------------------------------------------------
578
585
 
579
586
  export function NetworkInspectorPanel() {
@@ -583,8 +590,21 @@ export function NetworkInspectorPanel() {
583
590
  const adapterRef = useRef<NetworkingAdapter | null>(null);
584
591
  const [paused, setPaused] = useState(false);
585
592
  const [filter, setFilter] = useState('');
593
+ // Godot's Autostart: whether a game that starts running starts recording (kept per checkout).
594
+ const [autostart, setAutostart] = useState(
595
+ // Off unless chosen, as Godot's is: a run records once Start is pressed.
596
+ () => editorHost().projectLocalState.read<boolean>(AUTOSTART_SECTION) ?? false,
597
+ );
586
598
  // The one message draft: sent as this client, or from the server to one client or all.
587
- const [draft, setDraft] = useState<MessageDraft>({ type: '', payload: '' });
599
+ // Kept per checkout, as Monitor keeps its Send draft in its browser storage.
600
+ const [draft, setDraftState] = useState<MessageDraft>(
601
+ // Monitor's own first draft: `message_type` and an empty object.
602
+ () => editorHost().projectLocalState.read<MessageDraft>(DRAFT_SECTION) ?? { type: 'message_type', payload: '{}' },
603
+ );
604
+ const setDraft = (next: MessageDraft): void => {
605
+ editorHost().projectLocalState.write(DRAFT_SECTION, next);
606
+ setDraftState(next);
607
+ };
588
608
 
589
609
  useEffect(() => {
590
610
  const syncAdapter = () => {
@@ -696,9 +716,68 @@ export function NetworkInspectorPanel() {
696
716
  ownSession={view.roomInfo?.sessionId ?? null}
697
717
  ownRoom={view.roomInfo?.roomId ?? null}
698
718
  draft={draft}
719
+ setDraft={setDraft}
699
720
  />
700
721
  ) : null}
722
+ {caps.recording || caps.traffic ? (
723
+ // Godot's profiler bar, above the tables it governs: Start/Stop, Clear, Autostart.
724
+ <div
725
+ data-testid="net-profiler-bar"
726
+ style={{
727
+ display: 'flex',
728
+ gap: spaceVar[3],
729
+ alignItems: 'center',
730
+ padding: spaceVar[2],
731
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
732
+ }}
733
+ >
734
+ {caps.recording ? (
735
+ <>
736
+ <Button
737
+ type="button"
738
+ variant="ghost"
739
+ data-testid="net-recording"
740
+ aria-pressed={adapter.isRecording?.() === true}
741
+ onClick={() => {
742
+ adapter.setRecording?.(!adapter.isRecording?.());
743
+ force();
744
+ }}
745
+ >
746
+ {adapter.isRecording?.() ? 'Stop' : 'Start'}
747
+ </Button>
748
+ </>
749
+ ) : null}
750
+ <Button
751
+ type="button"
752
+ variant="ghost"
753
+ data-testid="net-log-clear"
754
+ onClick={() => {
755
+ // Godot's Clear resets its tables; the log clears with them.
756
+ logRef.current.clear();
757
+ adapter.clearTraffic?.();
758
+ force();
759
+ }}
760
+ >
761
+ Clear
762
+ </Button>
763
+ {caps.recording ? (
764
+ <label style={{ display: 'flex', gap: spaceVar[1], alignItems: 'center', whiteSpace: 'nowrap' }}>
765
+ <Checkbox
766
+ data-testid="net-autostart"
767
+ checked={autostart}
768
+ onChange={(event) => {
769
+ const on = event.currentTarget.checked;
770
+ editorHost().projectLocalState.write(AUTOSTART_SECTION, on);
771
+ setAutostart(on);
772
+ }}
773
+ />
774
+ Autostart
775
+ </label>
776
+ ) : null}
777
+ </div>
778
+ ) : null}
701
779
  {caps.traffic ? <TrafficTable rows={adapter.getTrafficByType?.() ?? []} /> : null}
780
+ {caps.entityTraffic ? <EntityTrafficTable rows={adapter.getTrafficByEntity?.() ?? []} /> : null}
702
781
  {caps.send ? <SendControls adapter={adapter} ping={caps.ping} draft={draft} setDraft={setDraft} /> : null}
703
782
 
704
783
  {/* Body: state tree | message log */}
@@ -747,31 +826,20 @@ export function NetworkInspectorPanel() {
747
826
  padding: spaceVar[2],
748
827
  }}
749
828
  >
750
- <Button
751
- type="button"
752
- variant="ghost"
753
- data-testid="net-log-pause"
754
- onClick={() => {
755
- const next = !paused;
756
- logRef.current.paused = next;
757
- setPaused(next);
758
- }}
759
- >
760
- {paused ? 'Resume' : 'Pause'}
761
- </Button>
762
- <Button
763
- type="button"
764
- variant="ghost"
765
- data-testid="net-log-clear"
766
- onClick={() => {
767
- // Godot's Clear resets its tables too: the log and the traffic totals.
768
- logRef.current.clear();
769
- adapter.clearTraffic?.();
770
- force();
771
- }}
772
- >
773
- Clear
774
- </Button>
829
+ {caps.recording ? null : (
830
+ <Button
831
+ type="button"
832
+ variant="ghost"
833
+ data-testid="net-log-pause"
834
+ onClick={() => {
835
+ const next = !paused;
836
+ logRef.current.paused = next;
837
+ setPaused(next);
838
+ }}
839
+ >
840
+ {paused ? 'Resume' : 'Pause'}
841
+ </Button>
842
+ )}
775
843
  <TextInput
776
844
  data-testid="net-log-filter"
777
845
  placeholder="Filter by type"
@@ -808,6 +876,277 @@ export function NetworkInspectorPanel() {
808
876
  * its synchronizers as two tables of counts and sizes; a Colyseus room's are its messages and its
809
877
  * state sync, which arrive here as rows of one table (`state`, `patch`, and each message type).
810
878
  */
879
+ /** Godot's synchronizer table, for a Colyseus room: each replicated entity's incoming syncs (the
880
+ * patches that changed it), the field changes they carried, and the size range of those patches. */
881
+ function EntityTrafficTable({ rows }: { rows: readonly NetEntityTraffic[] }) {
882
+ const cell = { padding: `0 ${spaceVar[3]}`, textAlign: 'right' as const, whiteSpace: 'nowrap' as const };
883
+ return (
884
+ <div
885
+ data-testid="net-entity-traffic"
886
+ style={{ maxHeight: 132, overflow: 'auto', borderBottom: `1px solid ${themeVars.boundary.default}` }}
887
+ >
888
+ {rows.length === 0 ? (
889
+ <AbsentNote>No state patches yet.</AbsentNote>
890
+ ) : (
891
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: fontSizeVar.sm, ...MONO }}>
892
+ <thead>
893
+ <tr style={{ color: themeVars.content.muted }}>
894
+ <th style={{ ...cell, textAlign: 'left' }}>Entity</th>
895
+ <th
896
+ style={cell}
897
+ title="Syncs in - out, as Godot pairs them: the state patches that changed it, and none out, because a Colyseus client sends no state"
898
+ >
899
+ Count
900
+ </th>
901
+ <th style={cell} title="Field changes those patches carried">Changes</th>
902
+ <th
903
+ style={cell}
904
+ title="Size in - out: the size range of the patches that carried it (they carried other entities too), and none out"
905
+ >
906
+ Size
907
+ </th>
908
+ </tr>
909
+ </thead>
910
+ <tbody>
911
+ {rows.map((row) => (
912
+ <tr key={row.path} data-testid="net-entity-traffic-row">
913
+ <td style={{ ...cell, textAlign: 'left' }}>{row.path}</td>
914
+ <td style={cell}>{`${row.syncs} - 0`}</td>
915
+ <td style={cell}>{row.changes}</td>
916
+ <td style={cell}>
917
+ {`${row.patchBytesMin === row.patchBytesMax ? `${row.patchBytesMin} B` : `${row.patchBytesMin}…${row.patchBytesMax} B`} - 0 B`}
918
+ </td>
919
+ </tr>
920
+ ))}
921
+ </tbody>
922
+ </table>
923
+ )}
924
+ </div>
925
+ );
926
+ }
927
+
928
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
929
+
930
+ /** A leaf's text as typed: a JSON literal when it reads as one (5, true, null, {}), else a string. */
931
+ function readLiteral(text: string): Json {
932
+ try {
933
+ return JSON.parse(text) as Json;
934
+ } catch {
935
+ return text;
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Colyseus Monitor's payload editor: the message's JSON as a tree whose values are edited in
941
+ * place, keys and items added and removed; the Text view is the same payload as raw JSON.
942
+ */
943
+ function PayloadEditor({ text, onChange }: { text: string; onChange: (text: string) => void }) {
944
+ const [view, setView] = useState<'tree' | 'text'>('tree');
945
+ let parsed: Json | undefined;
946
+ try {
947
+ parsed = text.trim() === '' ? {} : (JSON.parse(text) as Json);
948
+ } catch {
949
+ parsed = undefined;
950
+ }
951
+ const write = (next: Json) => onChange(JSON.stringify(next));
952
+ return (
953
+ <div data-testid="net-payload-editor" style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[1] }}>
954
+ <div style={{ display: 'flex', gap: spaceVar[1] }}>
955
+ {(['tree', 'text'] as const).map((key) => (
956
+ <Button
957
+ key={key}
958
+ type="button"
959
+ variant="ghost"
960
+ aria-pressed={view === key}
961
+ data-testid={`net-payload-view-${key}`}
962
+ onClick={() => setView(key)}
963
+ >
964
+ {key === 'tree' ? 'Tree' : 'Text'}
965
+ </Button>
966
+ ))}
967
+ </div>
968
+ {view === 'text' || parsed === undefined ? (
969
+ <>
970
+ <TextArea
971
+ data-testid="net-send-dialog-payload"
972
+ rows={6}
973
+ value={text}
974
+ onChange={(event) => onChange(event.target.value)}
975
+ style={MONO}
976
+ />
977
+ {parsed === undefined ? (
978
+ <span style={{ color: themeVars.content.muted }}>Not JSON yet, so the tree cannot show it.</span>
979
+ ) : null}
980
+ </>
981
+ ) : (
982
+ <div style={{ ...MONO, fontSize: fontSizeVar.sm }}>
983
+ <PayloadNode value={parsed} onChange={write} depth={0} />
984
+ </div>
985
+ )}
986
+ </div>
987
+ );
988
+ }
989
+
990
+ function PayloadNode({ value, onChange, depth }: { value: Json; onChange: (next: Json) => void; depth: number }) {
991
+ const [newKey, setNewKey] = useState('');
992
+ const [newValue, setNewValue] = useState('');
993
+ const pad = { paddingLeft: depth * 12 };
994
+ if (value !== null && typeof value === 'object') {
995
+ const isArray = Array.isArray(value);
996
+ const entries: [string, Json][] = isArray ? value.map((item, index) => [String(index), item]) : Object.entries(value);
997
+ const set = (key: string, next: Json) => {
998
+ if (isArray) onChange(value.map((item, index) => (String(index) === key ? next : item)));
999
+ else onChange({ ...value, [key]: next });
1000
+ };
1001
+ const remove = (key: string) => {
1002
+ if (isArray) onChange(value.filter((_, index) => String(index) !== key));
1003
+ else onChange(Object.fromEntries(Object.entries(value).filter(([entry]) => entry !== key)));
1004
+ };
1005
+ return (
1006
+ <div data-testid="net-payload-branch">
1007
+ {entries.map(([key, child]) => (
1008
+ <div key={key} style={pad}>
1009
+ <span style={{ color: themeVars.content.muted }}>{key}: </span>
1010
+ {child !== null && typeof child === 'object' ? (
1011
+ <span style={{ color: themeVars.content.muted }}>{Array.isArray(child) ? '[' : '{'}</span>
1012
+ ) : null}
1013
+ <button
1014
+ type="button"
1015
+ data-testid="net-payload-remove"
1016
+ title={`Remove ${key}`}
1017
+ onClick={() => remove(key)}
1018
+ style={{ background: 'none', border: 0, color: themeVars.content.muted, cursor: 'pointer' }}
1019
+ >
1020
+ ×
1021
+ </button>
1022
+ {child !== null && typeof child === 'object' ? (
1023
+ <PayloadNode value={child} onChange={(next) => set(key, next)} depth={depth + 1} />
1024
+ ) : (
1025
+ <PayloadLeaf value={child} onChange={(next) => set(key, next)} />
1026
+ )}
1027
+ </div>
1028
+ ))}
1029
+ <div style={{ ...pad, display: 'flex', gap: spaceVar[1] }}>
1030
+ {isArray ? null : (
1031
+ <TextInput
1032
+ data-testid="net-payload-new-key"
1033
+ placeholder="key"
1034
+ value={newKey}
1035
+ onChange={(event) => setNewKey(event.target.value)}
1036
+ style={{ width: 90 }}
1037
+ />
1038
+ )}
1039
+ <TextInput
1040
+ data-testid="net-payload-new-value"
1041
+ placeholder="value"
1042
+ value={newValue}
1043
+ onChange={(event) => setNewValue(event.target.value)}
1044
+ style={{ width: 110 }}
1045
+ />
1046
+ <Button
1047
+ type="button"
1048
+ variant="ghost"
1049
+ data-testid="net-payload-add"
1050
+ disabled={!isArray && newKey.trim() === ''}
1051
+ onClick={() => {
1052
+ const next = readLiteral(newValue);
1053
+ if (isArray) onChange([...value, next]);
1054
+ else onChange({ ...value, [newKey.trim()]: next });
1055
+ setNewKey('');
1056
+ setNewValue('');
1057
+ }}
1058
+ >
1059
+ Add
1060
+ </Button>
1061
+ </div>
1062
+ </div>
1063
+ );
1064
+ }
1065
+ return <PayloadLeaf value={value} onChange={onChange} />;
1066
+ }
1067
+
1068
+ type LeafType = 'string' | 'number' | 'boolean' | 'null';
1069
+
1070
+ /** A leaf keeps its type, as Monitor's editor does, until its type picker changes it. */
1071
+ function PayloadLeaf({ value, onChange }: { value: Json; onChange: (next: Json) => void }) {
1072
+ const type: LeafType =
1073
+ value === null ? 'null' : typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string';
1074
+ const shown = type === 'string' ? String(value) : JSON.stringify(value);
1075
+ const [text, setText] = useState(shown);
1076
+ useEffect(() => setText(shown), [shown]);
1077
+ const as = (to: LeafType, raw: string): Json => {
1078
+ if (to === 'null') return null;
1079
+ if (to === 'boolean') return raw.trim() === 'true';
1080
+ if (to === 'number') {
1081
+ const n = Number(raw);
1082
+ return Number.isFinite(n) ? n : 0;
1083
+ }
1084
+ return raw;
1085
+ };
1086
+ return (
1087
+ <span style={{ display: 'inline-flex', gap: 4 }}>
1088
+ {type === 'boolean' ? (
1089
+ <input
1090
+ type="checkbox"
1091
+ data-testid="net-payload-leaf-boolean"
1092
+ checked={value === true}
1093
+ onChange={(event) => onChange(event.currentTarget.checked)}
1094
+ />
1095
+ ) : type === 'null' ? (
1096
+ <span style={{ color: themeVars.content.muted }}>null</span>
1097
+ ) : (
1098
+ <TextInput
1099
+ data-testid="net-payload-leaf"
1100
+ value={text}
1101
+ onChange={(event) => setText(event.target.value)}
1102
+ onBlur={() => onChange(as(type, text))}
1103
+ onKeyDown={(event) => {
1104
+ if (event.key === 'Enter') onChange(as(type, text));
1105
+ }}
1106
+ style={{ width: 120 }}
1107
+ />
1108
+ )}
1109
+ <select
1110
+ data-testid="net-payload-leaf-type"
1111
+ aria-label="Value type"
1112
+ value={type}
1113
+ onChange={(event) => onChange(as(event.currentTarget.value as LeafType, text))}
1114
+ >
1115
+ {(['string', 'number', 'boolean', 'null'] as const).map((option) => (
1116
+ <option key={option} value={option}>
1117
+ {option}
1118
+ </option>
1119
+ ))}
1120
+ </select>
1121
+ </span>
1122
+ );
1123
+ }
1124
+
1125
+ type RoomSortKey = 'name' | 'roomId' | 'clients' | 'locked' | 'elapsedMs';
1126
+ const ROOM_COLUMNS: readonly (readonly [RoomSortKey, string])[] = [
1127
+ ['name', 'Name'],
1128
+ ['roomId', 'Room'],
1129
+ ['clients', 'Clients'],
1130
+ ['locked', 'Locked'],
1131
+ ['elapsedMs', 'Elapsed'],
1132
+ ];
1133
+
1134
+ function sortRooms<Room extends Record<RoomSortKey, unknown>>(
1135
+ rooms: readonly Room[],
1136
+ sort: { key: RoomSortKey; descending: boolean },
1137
+ ): Room[] {
1138
+ const value = (room: Room): number | string => {
1139
+ const field = room[sort.key];
1140
+ return typeof field === 'number' ? field : typeof field === 'boolean' ? Number(field) : String(field);
1141
+ };
1142
+ return [...rooms].sort((a, b) => {
1143
+ const left = value(a);
1144
+ const right = value(b);
1145
+ const order = left < right ? -1 : left > right ? 1 : 0;
1146
+ return sort.descending ? -order : order;
1147
+ });
1148
+ }
1149
+
811
1150
  function TrafficTable({ rows }: { rows: readonly NetTypeTraffic[] }) {
812
1151
  const cell = { padding: `0 ${spaceVar[3]}`, textAlign: 'right' as const, whiteSpace: 'nowrap' as const };
813
1152
  const bytes = (value: number) => (value >= 1024 ? `${(value / 1024).toFixed(1)} KiB` : `${value} B`);
@@ -945,14 +1284,23 @@ function ServerView({
945
1284
  ownSession,
946
1285
  ownRoom,
947
1286
  draft,
1287
+ setDraft,
948
1288
  }: {
949
1289
  adapter: NetworkingAdapter;
950
1290
  ownSession: string | null;
951
1291
  ownRoom: string | null;
952
1292
  draft: MessageDraft;
1293
+ setDraft: (draft: MessageDraft) => void;
953
1294
  }) {
1295
+ // Monitor's Send dialog: opened from a client's Send or from Broadcast, titled by its target,
1296
+ // editing the one message draft (which Monitor keeps too, in its browser storage).
1297
+ const [sendTarget, setSendTarget] = useState<{ sessionId: string | null } | null>(null);
1298
+ const [sendError, setSendError] = useState<string | null>(null);
1299
+ const [roomTab, setRoomTab] = useState<'clients' | 'state'>('clients');
954
1300
  const [inspection, setInspection] = useState<NetServerInspection | null | undefined>(undefined);
955
1301
  const [inspected, setInspected] = useState<string | undefined>(undefined);
1302
+ // Monitor's room grid sorts by any column.
1303
+ const [sort, setSort] = useState<{ key: RoomSortKey; descending: boolean }>({ key: 'elapsedMs', descending: true });
956
1304
  const [error, setError] = useState<string | null>(null);
957
1305
  useEffect(() => {
958
1306
  let live = true;
@@ -1013,8 +1361,25 @@ function ServerView({
1013
1361
  {inspection.room ? ` · state ${inspection.room.stateBytes} B` : ''}
1014
1362
  </div>
1015
1363
  <table style={{ borderCollapse: 'collapse', ...MONO }}>
1364
+ <thead>
1365
+ <tr style={{ color: themeVars.content.muted }}>
1366
+ {ROOM_COLUMNS.map(([key, label]) => (
1367
+ <th
1368
+ key={key}
1369
+ data-testid={`net-server-sort-${key}`}
1370
+ aria-sort={sort.key === key ? (sort.descending ? 'descending' : 'ascending') : 'none'}
1371
+ style={{ ...cell, textAlign: 'left', cursor: 'pointer', fontWeight: 'normal' }}
1372
+ onClick={() => setSort({ key, descending: sort.key === key ? !sort.descending : false })}
1373
+ >
1374
+ {label}
1375
+ {sort.key === key ? (sort.descending ? ' ▾' : ' ▴') : ''}
1376
+ </th>
1377
+ ))}
1378
+ <th style={cell} />
1379
+ </tr>
1380
+ </thead>
1016
1381
  <tbody>
1017
- {inspection.rooms.map((room) => (
1382
+ {sortRooms(inspection.rooms, sort).map((room) => (
1018
1383
  <tr key={room.roomId} data-testid="net-server-room" data-inspected={room.roomId === roomId || undefined}>
1019
1384
  <td style={cell}>{room.name}</td>
1020
1385
  <td style={cell}>{room.roomId}</td>
@@ -1026,22 +1391,115 @@ function ServerView({
1026
1391
  <td style={cell}>{seconds(room.elapsedMs)}</td>
1027
1392
  <td style={cell}>
1028
1393
  {room.roomId === roomId ? (
1029
- <>
1030
- {actButton('net-server-broadcast', 'Broadcast', () => {
1031
- const message = draftMessage(draft);
1032
- return adapter.broadcast?.(message.type, message.payload, room.roomId);
1033
- })}
1034
- {actButton('net-server-dispose', 'Dispose', () => adapter.disposeRoom?.(room.roomId))}
1035
- </>
1394
+ <Button
1395
+ type="button"
1396
+ variant="ghost"
1397
+ data-testid="net-server-broadcast"
1398
+ onClick={() => {
1399
+ setSendError(null);
1400
+ setSendTarget({ sessionId: null });
1401
+ }}
1402
+ >
1403
+ Broadcast
1404
+ </Button>
1036
1405
  ) : (
1037
1406
  <Button type="button" variant="ghost" data-testid="net-server-inspect" onClick={() => setInspected(room.roomId)}>
1038
1407
  Inspect
1039
1408
  </Button>
1040
1409
  )}
1410
+ {/* Monitor offers Dispose on every room of its list. */}
1411
+ {actButton('net-server-dispose', 'Dispose', () => adapter.disposeRoom?.(room.roomId))}
1041
1412
  </td>
1042
1413
  </tr>
1043
1414
  ))}
1044
- {inspection.room?.clients.map((client) => (
1415
+ </tbody>
1416
+ </table>
1417
+ {sendTarget ? (
1418
+ // Monitor's dialog, drawn in the panel rather than over the editor: during Play a field
1419
+ // over the editor would hand the game every key typed into it.
1420
+ <div
1421
+ data-testid="net-send-dialog"
1422
+ role="group"
1423
+ aria-labelledby="net-send-dialog-title"
1424
+ style={{ margin: spaceVar[2], padding: spaceVar[3], border: `1px solid ${themeVars.boundary.default}`, borderRadius: 4 }}
1425
+ >
1426
+ <div id="net-send-dialog-title" style={{ color: themeVars.content.primary, marginBottom: spaceVar[2] }}>
1427
+ {sendTarget.sessionId
1428
+ ? `Send message to client (${sendTarget.sessionId})`
1429
+ : 'Broadcast message to all clients'}
1430
+ </div>
1431
+ <div style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[2] }}>
1432
+ <label style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[1] }}>
1433
+ <span style={{ color: themeVars.content.muted }}>Message type</span>
1434
+ <TextInput
1435
+ data-testid="net-send-dialog-type"
1436
+ value={draft.type}
1437
+ onChange={(event) => setDraft({ ...draft, type: event.target.value })}
1438
+ />
1439
+ </label>
1440
+ <label style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[1] }}>
1441
+ <span style={{ color: themeVars.content.muted }}>Message payload (JSON)</span>
1442
+ <PayloadEditor text={draft.payload} onChange={(payload) => setDraft({ ...draft, payload })} />
1443
+ </label>
1444
+ {sendError ? <span data-testid="net-send-dialog-error">{sendError}</span> : null}
1445
+ </div>
1446
+ <div style={{ display: 'flex', gap: spaceVar[2], justifyContent: 'flex-end', marginTop: spaceVar[2] }}>
1447
+ <Button type="button" variant="ghost" onClick={() => setSendTarget(null)}>
1448
+ Cancel
1449
+ </Button>
1450
+ <Button
1451
+ type="button"
1452
+ data-testid="net-send-dialog-send"
1453
+ onClick={() => {
1454
+ const target = sendTarget;
1455
+ void (async () => {
1456
+ try {
1457
+ const message = draftMessage(draft);
1458
+ await (target.sessionId
1459
+ ? adapter.sendToClient?.(target.sessionId, message.type, message.payload, roomId)
1460
+ : adapter.broadcast?.(message.type, message.payload, roomId));
1461
+ setSendError(null);
1462
+ setSendTarget(null);
1463
+ } catch (caught) {
1464
+ setSendError(caught instanceof Error ? caught.message : String(caught));
1465
+ }
1466
+ })();
1467
+ }}
1468
+ >
1469
+ Send
1470
+ </Button>
1471
+ </div>
1472
+ </div>
1473
+ ) : null}
1474
+ {inspection.room ? (
1475
+ // Monitor's room view: a Clients tab and a State tab. The State tab is the SERVER's state
1476
+ // of the inspected room, edited and deleted there; the tree below this section stays this
1477
+ // client's replicated copy of its own room.
1478
+ <div data-testid="net-server-room-view" style={{ padding: `${spaceVar[2]} 0` }}>
1479
+ <div role="tablist" style={{ display: 'flex', gap: spaceVar[2], padding: `0 ${spaceVar[3]}` }}>
1480
+ {(
1481
+ [
1482
+ ['clients', `Clients (${inspection.room.clients.length})`],
1483
+ ['state', 'State'],
1484
+ ] as const
1485
+ ).map(([key, label]) => (
1486
+ <Button
1487
+ key={key}
1488
+ type="button"
1489
+ variant="ghost"
1490
+ role="tab"
1491
+ aria-selected={roomTab === key}
1492
+ data-testid={`net-server-tab-${key}`}
1493
+ onClick={() => setRoomTab(key)}
1494
+ >
1495
+ {label}
1496
+ </Button>
1497
+ ))}
1498
+ </div>
1499
+ {roomTab === 'clients' ? (
1500
+ <table style={{ borderCollapse: 'collapse', ...MONO }}>
1501
+ <tbody>
1502
+ {inspection.room.clients.map((client) => (
1045
1503
  <tr key={client.sessionId} data-testid="net-server-client">
1046
1504
  <td style={cell}>client</td>
1047
1505
  <td style={cell}>
@@ -1050,27 +1508,36 @@ function ServerView({
1050
1508
  </td>
1051
1509
  <td style={cell}>{seconds(client.elapsedMs)}</td>
1052
1510
  <td style={cell} colSpan={3}>
1053
- {actButton('net-server-send', 'Send', () => {
1054
- const message = draftMessage(draft);
1055
- return adapter.sendToClient?.(client.sessionId, message.type, message.payload, roomId);
1056
- })}
1511
+ <Button
1512
+ type="button"
1513
+ variant="ghost"
1514
+ data-testid="net-server-send"
1515
+ onClick={() => {
1516
+ setSendError(null);
1517
+ setSendTarget({ sessionId: client.sessionId });
1518
+ }}
1519
+ >
1520
+ Send
1521
+ </Button>
1057
1522
  {actButton('net-server-disconnect', 'Disconnect', () => adapter.disconnectClient?.(client.sessionId, roomId))}
1058
1523
  </td>
1059
1524
  </tr>
1060
1525
  ))}
1061
- </tbody>
1062
- </table>
1063
- {inspection.room && inspection.room.roomId !== ownRoom && inspection.room.state !== undefined ? (
1064
- // ANOTHER room's state, as Monitor's Inspect opens it: read from the server, and edited
1065
- // and deleted in THAT room. The tree below the section stays this client's own room.
1066
- <div data-testid="net-server-room-state" style={{ padding: `${spaceVar[2]} 0` }}>
1067
- <StateTreeNode
1068
- name={`room ${inspection.room.roomId}`}
1069
- value={inspection.room.state}
1070
- depth={0}
1071
- edit={adapter.editServerState ? (path, next) => adapter.editServerState!(path, next, roomId) : undefined}
1072
- remove={adapter.deleteServerState ? (path) => adapter.deleteServerState!(path, roomId) : undefined}
1073
- />
1526
+ </tbody>
1527
+ </table>
1528
+ ) : inspection.room.state !== undefined ? (
1529
+ <div data-testid="net-server-room-state">
1530
+ <StateTreeNode
1531
+ name={`room ${inspection.room.roomId}${inspection.room.roomId === ownRoom ? ' (this client’s room)' : ''}`}
1532
+ value={inspection.room.state}
1533
+ depth={0}
1534
+ edit={adapter.editServerState ? (path, next) => adapter.editServerState!(path, next, roomId) : undefined}
1535
+ remove={adapter.deleteServerState ? (path) => adapter.deleteServerState!(path, roomId) : undefined}
1536
+ />
1537
+ </div>
1538
+ ) : (
1539
+ <AbsentNote>The server sent no state for this room.</AbsentNote>
1540
+ )}
1074
1541
  </div>
1075
1542
  ) : null}
1076
1543
  {error ? <AbsentNote>{error}</AbsentNote> : null}