@knpkv/herdr-connect 0.3.1 → 0.4.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 (72) hide show
  1. package/README.md +2 -0
  2. package/dist/client.d.ts +986 -2
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +353 -54
  5. package/dist/client.js.map +1 -1
  6. package/dist/directory.d.ts +1 -1
  7. package/dist/directory.d.ts.map +1 -1
  8. package/dist/directory.js +1 -1
  9. package/dist/directory.js.map +1 -1
  10. package/dist/id.d.ts.map +1 -1
  11. package/dist/id.js +3 -2
  12. package/dist/id.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/terminal-release.d.ts.map +1 -1
  18. package/dist/internal/terminal-release.js +3 -1
  19. package/dist/internal/terminal-release.js.map +1 -1
  20. package/dist/model.d.ts.map +1 -1
  21. package/dist/model.js +3 -2
  22. package/dist/model.js.map +1 -1
  23. package/dist/terminal-dimensions.d.ts +25 -0
  24. package/dist/terminal-dimensions.d.ts.map +1 -0
  25. package/dist/terminal-dimensions.js +19 -0
  26. package/dist/terminal-dimensions.js.map +1 -0
  27. package/dist/terminal-keyboard.d.ts +52 -0
  28. package/dist/terminal-keyboard.d.ts.map +1 -0
  29. package/dist/terminal-keyboard.js +141 -0
  30. package/dist/terminal-keyboard.js.map +1 -0
  31. package/dist/terminal-output.d.ts +30 -0
  32. package/dist/terminal-output.d.ts.map +1 -0
  33. package/dist/terminal-output.js +54 -0
  34. package/dist/terminal-output.js.map +1 -0
  35. package/dist/terminal-rail-navigation.d.ts +4 -0
  36. package/dist/terminal-rail-navigation.d.ts.map +1 -0
  37. package/dist/terminal-rail-navigation.js +13 -0
  38. package/dist/terminal-rail-navigation.js.map +1 -0
  39. package/dist/terminal-viewport.d.ts +32 -2
  40. package/dist/terminal-viewport.d.ts.map +1 -1
  41. package/dist/terminal-viewport.js +131 -12
  42. package/dist/terminal-viewport.js.map +1 -1
  43. package/dist/terminal-worker-guard.d.ts +7 -0
  44. package/dist/terminal-worker-guard.d.ts.map +1 -0
  45. package/dist/terminal-worker-guard.js +11 -0
  46. package/dist/terminal-worker-guard.js.map +1 -0
  47. package/dist/terminal.d.ts +1661 -25
  48. package/dist/terminal.d.ts.map +1 -1
  49. package/dist/terminal.js +1 -1
  50. package/dist/terminal.js.map +1 -1
  51. package/dist/view.d.ts +15 -2
  52. package/dist/view.d.ts.map +1 -1
  53. package/dist/view.js +46 -2
  54. package/dist/view.js.map +1 -1
  55. package/dist/work-goal-link-view.d.ts +8 -0
  56. package/dist/work-goal-link-view.d.ts.map +1 -0
  57. package/dist/work-goal-link-view.js +3 -0
  58. package/dist/work-goal-link-view.js.map +1 -0
  59. package/dist/work-goal-link.d.ts +24 -0
  60. package/dist/work-goal-link.d.ts.map +1 -0
  61. package/dist/work-goal-link.js +34 -0
  62. package/dist/work-goal-link.js.map +1 -0
  63. package/dist/work-poll.d.ts +6 -0
  64. package/dist/work-poll.d.ts.map +1 -0
  65. package/dist/work-poll.js +7 -0
  66. package/dist/work-poll.js.map +1 -0
  67. package/dist/workspace-focus.d.ts +23 -0
  68. package/dist/workspace-focus.d.ts.map +1 -0
  69. package/dist/workspace-focus.js +69 -0
  70. package/dist/workspace-focus.js.map +1 -0
  71. package/package.json +17 -15
  72. package/src/styles.css +139 -12
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,EAAmC,MAAM,EAAE,MAAM,QAAQ,CAAA;AACvE,OAAO,KAAK,WAAW,MAAM,wCAAwC,CAAA;AACrE,OAAO,KAAK,IAAI,MAAM,iCAAiC,CAAA;AAIvD,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAG7E,OAAO,EACL,KAAK,YAAY,EAMlB,MAAM,YAAY,CAAA;AAOnB,OAAO,EAAqD,KAAK,mBAAmB,EAAE,MAAM,WAAW,CAAA;;;;AAOvG,cAAM,mBAAoB,SAAQ,wBAEhC;CAAG;;;;AAEL,cAAM,kBAAmB,SAAQ,uBAE/B;CAAG;;;;;AAEL,cAAM,oBAAqB,SAAQ,yBAGjC;CAAG;;;;;AAEL,cAAM,sBAAuB,SAAQ,2BAGnC;CAAG;AAML,KAAK,eAAe,GAChB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GAC7D;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GAC5D;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GACzD;IACE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;IACvB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB,CAAA;AAEL,KAAK,iBAAiB,GAAG;IACvB,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB,CAAA;AAwGD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAc5B,CAAA;AAED,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAA;AAoP9D,eAAO,MAAM,cAAc,oCAIxB;IACD,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,CAAA;CAChC,gCAuPA,CAAA"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.tsx"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -4,21 +4,30 @@ import { BrowserHttpClient } from "@effect/platform-browser";
4
4
  import { StateLabel, Surface, Text } from "@knpkv/rly/primitives";
5
5
  import { decodeBoundedResponseJson } from "@knpkv/herdr-fleet/response";
6
6
  import { Cause, Effect, Fiber, Result, Schedule, Schema } from "effect";
7
- import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
8
- import * as Atom from "effect/unstable/reactivity/Atom";
9
- import * as HttpClient from "effect/unstable/http/HttpClient";
7
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
8
+ import * as Atom from "effect/reactivity/Atom";
9
+ import * as HttpClient from "effect/http/HttpClient";
10
10
  import { FitAddon, init, Terminal } from "ghostty-web";
11
- import { useEffect, useRef } from "react";
11
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
12
12
  import { buildConnectForest } from "./forest.js";
13
13
  import { applyTerminalInputIdentity } from "./terminal-input-identity.js";
14
+ import { clampTerminalDimensions, terminalResizeCommand } from "./terminal-dimensions.js";
14
15
  import { FleetConnectAgentPage, FleetConnectAgents, TerminalServerSignal } from "./model.js";
15
16
  import { makePendingTerminalInput, makeTouchScrollGesture, pageScrollCommand, wheelScrollCommand } from "./terminal-input.js";
16
- import { AgentDirectory, connectAgentKey, ConnectWorkspace } from "./view.js";
17
+ import { makeTerminalInputHandler, makeTerminalOutputBoundary, writeTerminalOutput } from "./terminal-output.js";
18
+ import { AgentDirectory, connectAgentKey, ConnectWorkspace, TerminalKeyRail } from "./view.js";
17
19
  import { acquireTerminalSetup, ConnectTerminalSetupError } from "./terminal-setup.js";
18
20
  import { terminalBackground } from "./terminal-theme.js";
19
- import { bindTerminalViewport } from "./terminal-viewport.js";
21
+ import { bindTerminalDocumentLock, bindTerminalViewport, terminalViewportBindingActive } from "./terminal-viewport.js";
20
22
  import { resolveConnectPreferenceDecision } from "./target.js";
21
23
  import { nextConnectAgentIndex } from "./keyboard.js";
24
+ import { applyTerminalModifierToInput, dispatchTerminalKey, toggleTerminalModifier } from "./terminal-keyboard.js";
25
+ import { WorkSnapshots } from "@knpkv/herdr-work/model";
26
+ import { ConnectAgentIdentity } from "./work-goal-link-view.js";
27
+ import { resolveConnectWorkGoal, workSnapshotForAssociation } from "./work-goal-link.js";
28
+ import { WorkPollMount } from "./work-poll.js";
29
+ import { makeTerminalWorkerGuard } from "./terminal-worker-guard.js";
30
+ import { enterTerminalWorkspaceWithLock, returnToDirectoryWorkspace } from "./workspace-focus.js";
22
31
  class ConnectNetworkError extends Schema.TaggedError()("ConnectNetworkError", {
23
32
  detail: Schema.String
24
33
  }) {
@@ -100,9 +109,23 @@ const loadAgents = Effect.gen(function* () {
100
109
  })));
101
110
  return directory;
102
111
  });
112
+ const loadWork = Effect.gen(function* () {
113
+ const client = yield* HttpClient.HttpClient;
114
+ const response = yield* client
115
+ .get("/v1/work")
116
+ .pipe(Effect.mapError((cause) => new ConnectNetworkError({ detail: String(cause) })));
117
+ if (response.status < 200 || response.status >= 300) {
118
+ return yield* new ConnectStatusError({ status: response.status });
119
+ }
120
+ return yield* decodeBoundedResponseJson(response, WorkSnapshots).pipe(Effect.mapError((cause) => new ConnectProtocolError({
121
+ detail: "invalid Work snapshot",
122
+ cause
123
+ })));
124
+ });
103
125
  const browserRuntime = Atom.runtime(BrowserHttpClient.layerFetch);
104
126
  export const makeConnectAtoms = () => {
105
127
  const agents = browserRuntime.atom(loadAgents);
128
+ const work = browserRuntime.atom(loadWork);
106
129
  return {
107
130
  activityFilter: Atom.make("all"),
108
131
  agents,
@@ -113,10 +136,13 @@ export const makeConnectAtoms = () => {
113
136
  preference: Atom.make(loadRememberedAgent),
114
137
  preferenceError: Atom.make(null),
115
138
  query: Atom.make(""),
116
- selectedKey: Atom.make(null)
139
+ selectedKey: Atom.make(null),
140
+ work,
141
+ workPoll: browserRuntime.atom(Atom.refresh(work).pipe(Effect.repeat(Schedule.spaced("5 seconds"))))
117
142
  };
118
143
  };
119
- const socketUrl = (agent, cols, rows) => {
144
+ const socketUrl = (agent, dimensions) => {
145
+ const { cols, rows } = clampTerminalDimensions(dimensions);
120
146
  const url = new URL("/v1/connect/session", window.location.href);
121
147
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
122
148
  url.searchParams.set("host", agent.host);
@@ -125,7 +151,18 @@ const socketUrl = (agent, cols, rows) => {
125
151
  url.searchParams.set("rows", String(rows));
126
152
  return url.toString();
127
153
  };
128
- const terminalWorker = (container, agent, update) => Effect.scoped(Effect.gen(function* () {
154
+ const renderTerminalOutput = (terminal, data, outputBoundary, onError) => {
155
+ Effect.runFork(Effect.try({
156
+ try: () => {
157
+ writeTerminalOutput(terminal, data, outputBoundary);
158
+ },
159
+ catch: (cause) => new ConnectProtocolError({
160
+ detail: "terminal output could not be rendered",
161
+ cause
162
+ })
163
+ }).pipe(Effect.catch((error) => Effect.sync(() => onError(error)))));
164
+ };
165
+ const terminalWorker = (container, agent, update, keyboard) => Effect.scoped(Effect.gen(function* () {
129
166
  update({ _tag: "connecting", agent });
130
167
  yield* Effect.tryPromise({
131
168
  try: init,
@@ -167,42 +204,62 @@ const terminalWorker = (container, agent, update) => Effect.scoped(Effect.gen(fu
167
204
  });
168
205
  }
169
206
  applyTerminalInputIdentity(textarea);
207
+ const releaseTerminalFocus = keyboard.setTerminalFocus(textarea, () => terminal.terminal.focus());
208
+ yield* Effect.addFinalizer(() => Effect.sync(releaseTerminalFocus));
170
209
  let ready = false;
171
210
  let socket = null;
172
211
  let inputOverflow = false;
212
+ const outputBoundary = makeTerminalOutputBoundary();
173
213
  let pendingResize = null;
174
214
  const pendingInput = makePendingTerminalInput();
175
215
  const send = (command) => {
176
216
  if (socket?.readyState === WebSocket.OPEN) {
177
217
  socket.send(JSON.stringify(command));
218
+ return true;
178
219
  }
220
+ return false;
179
221
  };
180
- const input = terminal.terminal.onData((text) => {
181
- if (ready) {
182
- send({ type: "terminal.input", text });
222
+ const sendInput = (text) => send({ type: "terminal.input", text });
223
+ const releaseInputSender = keyboard.setInputSender((command) => send(command));
224
+ yield* Effect.addFinalizer(() => Effect.sync(releaseInputSender));
225
+ const releaseCursorModeReader = keyboard.setCursorModeReader(() => terminal.terminal.getMode(1) ? "application" : "normal");
226
+ yield* Effect.addFinalizer(() => Effect.sync(releaseCursorModeReader));
227
+ const applyInput = (text) => {
228
+ const application = applyTerminalModifierToInput(keyboard.getModifier(), text);
229
+ if (application._tag === "unsupported") {
230
+ keyboard.reportError(application);
231
+ return null;
183
232
  }
184
- else if (pendingInput.push(text) === "overflow") {
185
- inputOverflow = true;
186
- update({
187
- _tag: "failed",
188
- agent,
189
- detail: "terminal input queue exceeded 64 KiB before ready"
190
- });
191
- socket?.close(4429, "terminal input queue limit reached");
192
- }
193
- });
233
+ return application;
234
+ };
235
+ const input = terminal.terminal.onData(makeTerminalInputHandler({
236
+ applyInput,
237
+ isReady: () => ready,
238
+ onFailure: (failure) => {
239
+ if (failure === "input_queue_overflow") {
240
+ inputOverflow = true;
241
+ update({
242
+ _tag: "failed",
243
+ agent,
244
+ detail: "terminal input queue exceeded 64 KiB before ready"
245
+ });
246
+ socket?.close(4429, "terminal input queue limit reached");
247
+ return;
248
+ }
249
+ update({ _tag: "failed", agent, detail: "terminal input could not be sent" });
250
+ socket?.close(4429, "terminal input unavailable");
251
+ },
252
+ outputBoundary,
253
+ pendingInput,
254
+ sendInput,
255
+ setModifier: keyboard.setModifier
256
+ }));
194
257
  const resize = terminal.terminal.onResize(({ cols, rows }) => {
195
258
  if (!ready) {
196
259
  pendingResize = { cols, rows };
197
260
  return;
198
261
  }
199
- send({
200
- type: "terminal.resize",
201
- cols,
202
- rows,
203
- cell_width_px: 0,
204
- cell_height_px: 0
205
- });
262
+ send(terminalResizeCommand({ cols, rows }));
206
263
  });
207
264
  terminal.terminal.attachCustomWheelEventHandler((event) => {
208
265
  const command = wheelScrollCommand(event, terminal.terminal.rows);
@@ -263,7 +320,7 @@ const terminalWorker = (container, agent, update) => Effect.scoped(Effect.gen(fu
263
320
  container.removeEventListener("touchcancel", touchCancel);
264
321
  }));
265
322
  const connectedSocket = yield* Effect.acquireRelease(Effect.callback((resume) => {
266
- const value = new WebSocket(socketUrl(agent, terminal.terminal.cols, terminal.terminal.rows));
323
+ const value = new WebSocket(socketUrl(agent, { cols: terminal.terminal.cols, rows: terminal.terminal.rows }));
267
324
  value.binaryType = "arraybuffer";
268
325
  let settled = false;
269
326
  value.addEventListener("open", () => {
@@ -292,7 +349,10 @@ const terminalWorker = (container, agent, update) => Effect.scoped(Effect.gen(fu
292
349
  const message = (event) => {
293
350
  const binary = Schema.decodeUnknownResult(Schema.instanceOf(ArrayBuffer))(event.data);
294
351
  if (Result.isSuccess(binary)) {
295
- terminal.terminal.write(new Uint8Array(binary.success));
352
+ renderTerminalOutput(terminal.terminal, new Uint8Array(binary.success), outputBoundary, (error) => {
353
+ update({ _tag: "failed", agent, detail: error.detail });
354
+ connectedSocket.close(4400, "terminal output could not be rendered");
355
+ });
296
356
  return;
297
357
  }
298
358
  const decoded = Schema.decodeUnknownResult(Schema.fromJsonString(TerminalServerSignal))(event.data);
@@ -309,21 +369,16 @@ const terminalWorker = (container, agent, update) => Effect.scoped(Effect.gen(fu
309
369
  ready = true;
310
370
  const queued = pendingInput.drain();
311
371
  if (queued.length > 0) {
312
- send({ type: "terminal.input", text: queued });
372
+ if (!sendInput(queued)) {
373
+ update({ _tag: "failed", agent, detail: "terminal input could not be sent" });
374
+ connectedSocket.close(4429, "terminal input unavailable");
375
+ return;
376
+ }
313
377
  }
314
378
  if (pendingResize !== null) {
315
- send({
316
- type: "terminal.resize",
317
- cols: pendingResize.cols,
318
- rows: pendingResize.rows,
319
- cell_width_px: 0,
320
- cell_height_px: 0
321
- });
379
+ send(terminalResizeCommand(pendingResize));
322
380
  pendingResize = null;
323
381
  }
324
- if (window.matchMedia("(pointer: fine)").matches) {
325
- terminal.terminal.focus();
326
- }
327
382
  update({ _tag: "connected", agent });
328
383
  }
329
384
  };
@@ -355,28 +410,206 @@ export const ConnectSurface = ({ atoms, embedded = false, roomFooter }) => {
355
410
  const [preferenceError, setPreferenceError] = useAtom(atoms.preferenceError);
356
411
  const [query, setQuery] = useAtom(atoms.query);
357
412
  const [selectedKey, setSelectedKey] = useAtom(atoms.selectedKey);
413
+ const work = useAtomValue(atoms.work);
358
414
  const preferenceApplied = useRef(false);
359
- const requestId = useRef(0);
415
+ const requestId = useRef(connectionRequest?.id ?? 0);
416
+ const directorySearchRef = useRef(null);
417
+ const directoryViewportRef = useRef(null);
418
+ const shellRef = useRef(null);
419
+ const [shellElement, setShellElement] = useState(null);
420
+ const terminalActiveRequestRef = useRef(null);
421
+ const terminalEntryLockRef = useRef(null);
422
+ const terminalBackRef = useRef(null);
360
423
  const terminalRef = useRef(null);
424
+ const terminalFocusTargetRef = useRef(null);
361
425
  const terminalViewportRef = useRef(null);
426
+ const workspaceRef = useRef(null);
427
+ const terminalInputRef = useRef(() => false);
428
+ const terminalInputOwnerRef = useRef(null);
429
+ const terminalFocusRef = useRef(() => { });
430
+ const terminalCursorModeReaderRef = useRef(() => "normal");
431
+ const terminalCursorModeOwnerRef = useRef(null);
432
+ const terminalModifierRef = useRef(null);
433
+ const [terminalModifier, setTerminalModifier] = useState(null);
434
+ const [terminalKeyError, setTerminalKeyError] = useState(null);
435
+ const [workspaceFocusFailure, setWorkspaceFocusFailure] = useState(null);
362
436
  useAtomMount(atoms.agentsPoll);
437
+ const releaseTerminalEntryLock = useCallback(() => {
438
+ const release = terminalEntryLockRef.current;
439
+ terminalEntryLockRef.current = null;
440
+ release?.();
441
+ }, []);
442
+ const attachShell = useCallback((element) => {
443
+ shellRef.current = element;
444
+ setShellElement(element);
445
+ }, []);
446
+ const workspaceElements = () => {
447
+ const directoryScreen = directoryViewportRef.current;
448
+ const terminalScreen = terminalViewportRef.current;
449
+ const workspace = workspaceRef.current;
450
+ return directoryScreen === null || terminalScreen === null || workspace === null
451
+ ? null
452
+ : { directory: directoryScreen, terminal: terminalScreen, workspace };
453
+ };
454
+ const directoryFocusTarget = (agent) => {
455
+ const directoryScreen = directoryViewportRef.current;
456
+ if (directoryScreen === null)
457
+ return null;
458
+ const key = connectAgentKey(agent);
459
+ return ([...directoryScreen.querySelectorAll(".connect-agent")].find((button) => button.dataset.agentKey === key) ??
460
+ directorySearchRef.current ??
461
+ directoryScreen);
462
+ };
463
+ const restoreDirectoryFocus = (agent) => {
464
+ const elements = workspaceElements();
465
+ const focusTarget = directoryFocusTarget(agent);
466
+ if (elements !== null && focusTarget !== null)
467
+ return returnToDirectoryWorkspace(elements, focusTarget);
468
+ const terminalScreen = terminalViewportRef.current;
469
+ const activeElement = Schema.decodeUnknownResult(Schema.instanceOf(HTMLElement))(window.document.activeElement);
470
+ if (terminalScreen !== null && Result.isSuccess(activeElement) && terminalScreen.contains(activeElement.success)) {
471
+ activeElement.success.blur();
472
+ }
473
+ return { _tag: "failed", reason: "detached_element" };
474
+ };
363
475
  useEffect(() => {
364
476
  const container = terminalRef.current;
365
477
  if (connectionRequest === null || container === null)
366
478
  return;
367
- const fiber = Effect.runFork(terminalWorker(container, connectionRequest.agent, setConnection));
479
+ const terminalRequestId = connectionRequest.id;
480
+ const workerGuard = makeTerminalWorkerGuard(terminalRequestId);
481
+ const releaseTerminalFocus = () => {
482
+ workerGuard.release();
483
+ terminalFocusRef.current = () => { };
484
+ terminalFocusTargetRef.current = null;
485
+ terminalActiveRequestRef.current = null;
486
+ };
487
+ const invalidateTerminalRequest = () => {
488
+ if (requestId.current === terminalRequestId)
489
+ requestId.current += 1;
490
+ setConnectionRequest(null);
491
+ };
492
+ const fiber = Effect.runFork(terminalWorker(container, connectionRequest.agent, (state) => {
493
+ if (!workerGuard.accepts(requestId.current))
494
+ return;
495
+ if (state._tag === "connected") {
496
+ const elements = workspaceElements();
497
+ const focusTarget = window.matchMedia("(pointer: fine)").matches
498
+ ? terminalFocusTargetRef.current
499
+ : terminalBackRef.current;
500
+ if (elements === null || focusTarget === null) {
501
+ releaseTerminalFocus();
502
+ invalidateTerminalRequest();
503
+ setConnection({
504
+ _tag: "failed",
505
+ agent: state.agent,
506
+ detail: "terminal focus transition failed: detached_element"
507
+ });
508
+ return;
509
+ }
510
+ releaseTerminalEntryLock();
511
+ const lockedTransition = enterTerminalWorkspaceWithLock(elements, focusTarget, () => bindTerminalDocumentLock(window));
512
+ terminalEntryLockRef.current = lockedTransition.releaseLock;
513
+ const transition = lockedTransition.transition;
514
+ if (transition._tag === "failed") {
515
+ releaseTerminalEntryLock();
516
+ releaseTerminalFocus();
517
+ invalidateTerminalRequest();
518
+ setConnection({
519
+ _tag: "failed",
520
+ agent: state.agent,
521
+ detail: `terminal focus transition failed: ${transition.reason}`
522
+ });
523
+ return;
524
+ }
525
+ setWorkspaceFocusFailure(null);
526
+ terminalActiveRequestRef.current = terminalRequestId;
527
+ }
528
+ else if (state._tag === "closed" || state._tag === "failed") {
529
+ if (terminalActiveRequestRef.current === terminalRequestId) {
530
+ const transition = restoreDirectoryFocus(state.agent);
531
+ if (transition._tag === "failed") {
532
+ setWorkspaceFocusFailure(transition.reason);
533
+ }
534
+ else {
535
+ setWorkspaceFocusFailure(null);
536
+ }
537
+ terminalActiveRequestRef.current = null;
538
+ }
539
+ invalidateTerminalRequest();
540
+ }
541
+ setConnection(state);
542
+ }, {
543
+ getModifier: () => terminalModifierRef.current,
544
+ setTerminalFocus: (target, focus) => {
545
+ terminalFocusTargetRef.current = target;
546
+ terminalFocusRef.current = focus;
547
+ return () => {
548
+ if (terminalFocusRef.current === focus)
549
+ terminalFocusRef.current = () => { };
550
+ if (terminalFocusTargetRef.current === target)
551
+ terminalFocusTargetRef.current = null;
552
+ };
553
+ },
554
+ reportError: () => setTerminalKeyError("That modifier combination is not supported."),
555
+ setInputSender: (sendInput) => {
556
+ const owner = Symbol("terminal-input-sender");
557
+ terminalInputOwnerRef.current = owner;
558
+ terminalInputRef.current = sendInput;
559
+ return () => {
560
+ if (terminalInputOwnerRef.current !== owner)
561
+ return;
562
+ terminalInputOwnerRef.current = null;
563
+ terminalInputRef.current = () => false;
564
+ };
565
+ },
566
+ setCursorModeReader: (read) => {
567
+ const owner = Symbol("terminal-cursor-mode-reader");
568
+ terminalCursorModeOwnerRef.current = owner;
569
+ terminalCursorModeReaderRef.current = read;
570
+ return () => {
571
+ if (terminalCursorModeOwnerRef.current !== owner)
572
+ return;
573
+ terminalCursorModeOwnerRef.current = null;
574
+ terminalCursorModeReaderRef.current = () => "normal";
575
+ };
576
+ },
577
+ setModifier: (modifier) => {
578
+ terminalModifierRef.current = modifier;
579
+ setTerminalModifier((current) => (current === modifier ? current : modifier));
580
+ setTerminalKeyError(null);
581
+ }
582
+ }));
368
583
  return () => {
584
+ releaseTerminalEntryLock();
585
+ workerGuard.release();
369
586
  Effect.runFork(Fiber.interrupt(fiber));
370
587
  container.replaceChildren();
371
588
  };
372
- }, [connectionRequest, setConnection]);
373
- const terminalConnected = connection._tag === "connected";
374
- useEffect(() => {
589
+ }, [connectionRequest, releaseTerminalEntryLock, setConnection]);
590
+ const terminalVisible = connection._tag === "connected" || workspaceFocusFailure === "focus_rejected";
591
+ const terminalViewportActive = terminalViewportBindingActive({
592
+ connectionRequested: connectionRequest !== null,
593
+ focusRejected: workspaceFocusFailure === "focus_rejected",
594
+ terminalConnected: connection._tag === "connected"
595
+ });
596
+ // Size the hidden terminal before its first visible frame so Fleet navigation never overlaps it.
597
+ useLayoutEffect(() => {
375
598
  const room = terminalViewportRef.current;
376
- if (!terminalConnected || room === null)
599
+ if (!terminalViewportActive || room === null)
377
600
  return;
378
- return bindTerminalViewport(room, window);
379
- }, [terminalConnected]);
601
+ const attachedShell = shellElement ?? shellRef.current;
602
+ const topBoundary = embedded ? attachedShell : undefined;
603
+ if (topBoundary === null)
604
+ return;
605
+ try {
606
+ return bindTerminalViewport(room, window, topBoundary, terminalVisible);
607
+ }
608
+ finally {
609
+ if (terminalVisible)
610
+ releaseTerminalEntryLock();
611
+ }
612
+ }, [embedded, releaseTerminalEntryLock, shellElement, terminalViewportActive, terminalVisible]);
380
613
  const current = AsyncResult.isSuccess(directory)
381
614
  ? directory.value
382
615
  : directory._tag === "Failure" && directory.previousSuccess._tag === "Some"
@@ -387,9 +620,26 @@ export const ConnectSurface = ({ atoms, embedded = false, roomFooter }) => {
387
620
  (connectionRequest !== null && connectAgentKey(connectionRequest.agent) === selectedKey
388
621
  ? connectionRequest.agent
389
622
  : null);
623
+ const currentWork = workSnapshotForAssociation(work);
624
+ const workGoalResolution = selected === null
625
+ ? { _tag: "unavailable", reason: "snapshot_unavailable" }
626
+ : currentWork === null
627
+ ? { _tag: "unavailable", reason: "snapshot_unavailable" }
628
+ : resolveConnectWorkGoal(selected, currentWork);
390
629
  const selectAgent = (agent) => {
391
630
  preferenceApplied.current = true;
392
631
  const key = connectAgentKey(agent);
632
+ terminalInputOwnerRef.current = null;
633
+ terminalInputRef.current = () => false;
634
+ terminalFocusRef.current = () => { };
635
+ terminalFocusTargetRef.current = null;
636
+ terminalCursorModeOwnerRef.current = null;
637
+ terminalCursorModeReaderRef.current = () => "normal";
638
+ terminalModifierRef.current = null;
639
+ terminalActiveRequestRef.current = null;
640
+ setTerminalModifier(null);
641
+ setTerminalKeyError(null);
642
+ setWorkspaceFocusFailure(null);
393
643
  setSelectedKey(key);
394
644
  setConnection({ _tag: "connecting", agent });
395
645
  requestId.current += 1;
@@ -436,8 +686,53 @@ export const ConnectSurface = ({ atoms, embedded = false, roomFooter }) => {
436
686
  next.focus();
437
687
  };
438
688
  const disconnect = () => {
689
+ let nextConnection = { _tag: "idle" };
690
+ let nextFocusFailure = null;
691
+ if (connection._tag !== "idle" && (connection._tag === "connected" || workspaceFocusFailure === "focus_rejected")) {
692
+ const transition = restoreDirectoryFocus(connection.agent);
693
+ if (transition._tag === "failed") {
694
+ nextConnection = {
695
+ _tag: "failed",
696
+ agent: connection.agent,
697
+ detail: `terminal focus transition failed: ${transition.reason}`
698
+ };
699
+ nextFocusFailure = transition.reason;
700
+ }
701
+ }
702
+ requestId.current += 1;
703
+ terminalActiveRequestRef.current = null;
704
+ setConnection(nextConnection);
439
705
  setConnectionRequest(null);
440
- setConnection({ _tag: "idle" });
706
+ terminalInputOwnerRef.current = null;
707
+ terminalInputRef.current = () => false;
708
+ terminalFocusRef.current = () => { };
709
+ terminalFocusTargetRef.current = null;
710
+ terminalCursorModeOwnerRef.current = null;
711
+ terminalCursorModeReaderRef.current = () => "normal";
712
+ terminalModifierRef.current = null;
713
+ setTerminalModifier(null);
714
+ setTerminalKeyError(null);
715
+ setWorkspaceFocusFailure(nextFocusFailure);
716
+ };
717
+ const changeTerminalModifier = (modifier) => {
718
+ const next = toggleTerminalModifier(terminalModifierRef.current, modifier);
719
+ terminalModifierRef.current = next;
720
+ setTerminalModifier(next);
721
+ setTerminalKeyError(null);
722
+ };
723
+ const sendTerminalRailKey = (key) => {
724
+ const dispatch = dispatchTerminalKey(key, terminalModifierRef.current, terminalCursorModeReaderRef.current());
725
+ if (dispatch._tag === "unsupported") {
726
+ setTerminalKeyError("That modifier combination is not supported.");
727
+ return;
728
+ }
729
+ if (!terminalInputRef.current(dispatch.command)) {
730
+ setTerminalKeyError("Terminal connection is unavailable.");
731
+ return;
732
+ }
733
+ terminalModifierRef.current = dispatch.nextModifier;
734
+ setTerminalModifier(dispatch.nextModifier);
735
+ setTerminalKeyError(null);
441
736
  };
442
737
  const directoryScreen = (_jsxs(_Fragment, { children: [embedded ? (_jsxs("header", { className: "connect-embedded-intro", children: [_jsxs("div", { children: [_jsx(Text, { variant: "meta", tone: "secondary", children: "Live fleet directory" }), _jsx(Text, { as: "h1", variant: "page-title", children: "Connect to an agent" }), _jsx(Text, { tone: "secondary", children: "Choose a worker, reviewer, or coordinator to open its exact terminal." })] }), _jsx(StateLabel, { label: current === null ? "Loading" : `${String(agents.length)} agents`, size: "compact", tone: current === null ? "neutral" : "positive" })] })) : (_jsxs("header", { className: "connect-header", children: [_jsxs("div", { children: [_jsx(Text, { variant: "meta", tone: "secondary", children: "Herdr fleet" }), _jsx(Text, { as: "h1", variant: "page-title", children: "Connect" })] }), _jsxs("nav", { className: "fleet-app-nav", "aria-label": "Fleet applications", children: [_jsx("a", { href: "/", children: "Approvals" }), _jsx("a", { href: "/connect/", "aria-current": "page", children: "Connect" })] })] })), _jsxs("section", { className: "connect-agents", "aria-label": "Herdr agents", onKeyDown: moveAgentFocus, children: [_jsxs("label", { className: "connect-search", children: [_jsx("span", { children: "Find agent" }), _jsx("input", { autoComplete: "off", id: "connect-agent-search", name: "agent-search", onChange: (event) => setQuery(event.currentTarget.value), onKeyDown: (event) => {
443
738
  if (event.key === "Escape") {
@@ -455,8 +750,12 @@ export const ConnectSurface = ({ atoms, embedded = false, roomFooter }) => {
455
750
  return;
456
751
  event.preventDefault();
457
752
  firstAgent.focus();
458
- }, placeholder: "Name, host, state\u2026", type: "search", value: query })] }), current === null ? (_jsx(Text, { tone: "secondary", children: directory._tag === "Failure" ? Cause.pretty(directory.cause) : "Loading fleet agents…" })) : agents.length === 0 ? (_jsx(Text, { tone: "secondary", children: "No live agents." })) : (_jsx(AgentDirectory, { activityFilter: activityFilter, agents: agents, hostFilter: hostFilter, onActivityFilter: setActivityFilter, onHostFilter: setHostFilter, onSelect: selectAgent, query: query, selectedKey: selectedKey })), connection._tag === "connecting" ? (_jsxs("small", { className: "connect-status-message", children: ["Connecting to ", connection.agent.name, "\u2026"] })) : connection._tag === "failed" ? (_jsxs("small", { className: "connect-status-message", "data-tone": "critical", children: [connection.agent.name, " \u00B7 ", connection.detail] })) : connection._tag === "closed" ? (_jsxs("small", { className: "connect-status-message", children: [connection.agent.name, " disconnected."] })) : null, remembered._tag === "Failure" ? (_jsxs("small", { className: "connect-preference-error", children: ["Selection memory unavailable \u00B7 ", Cause.pretty(remembered.cause)] })) : preferenceError === null ? null : (_jsxs("small", { className: "connect-preference-error", children: ["Selection memory unavailable \u00B7 ", preferenceError] })), (current?.failures.length ?? 0) === 0 ? null : (_jsx("div", { className: "connect-failures", children: current?.failures.map((failure) => (_jsxs("small", { children: [failure.host, " \u00B7 ", failure.reason.replaceAll("_", " ")] }, failure.host))) }))] })] }));
459
- const terminalScreen = (_jsxs(Surface, { as: "section", padding: "none", className: "terminal-stage", children: [_jsxs("div", { className: "terminal-bar", children: [_jsx("button", { className: "terminal-back", onClick: disconnect, type: "button", children: "Agents" }), _jsxs("div", { children: [_jsx("strong", { children: selected?.name ?? "Agent" }), _jsx("small", { children: selected === null ? "Herdr terminal" : `${selected.host} · ${selected.kind}` })] }), _jsx(StateLabel, { label: "connected", tone: "positive", size: "compact" })] }), _jsx("div", { "aria-label": selected === null ? "Agent terminal" : `${selected.name} terminal`, className: "ghostty-terminal", ref: terminalRef })] }));
460
- return (_jsxs("div", { className: embedded ? "connect-shell connect-shell-embedded" : "connect-shell", children: [_jsx(ConnectWorkspace, { directory: directoryScreen, mode: connection._tag === "connected" ? "terminal" : "directory", terminal: terminalScreen, terminalViewportRef: terminalViewportRef }), roomFooter] }));
753
+ }, placeholder: "Name, host, state\u2026", ref: directorySearchRef, type: "search", value: query })] }), current === null ? (_jsx(Text, { tone: "secondary", children: directory._tag === "Failure" ? Cause.pretty(directory.cause) : "Loading fleet agents…" })) : agents.length === 0 ? (_jsx(Text, { tone: "secondary", children: "No live agents." })) : (_jsx(AgentDirectory, { activityFilter: activityFilter, agents: agents, hostFilter: hostFilter, onActivityFilter: setActivityFilter, onHostFilter: setHostFilter, onSelect: selectAgent, query: query, selectedKey: selectedKey })), connection._tag === "connecting" ? (_jsxs("small", { className: "connect-status-message", children: ["Connecting to ", connection.agent.name, "\u2026"] })) : connection._tag === "failed" ? (_jsxs("small", { className: "connect-status-message", "data-tone": "critical", children: [connection.agent.name, " \u00B7 ", connection.detail] })) : connection._tag === "closed" ? (_jsxs("small", { className: "connect-status-message", children: [connection.agent.name, " disconnected."] })) : null, remembered._tag === "Failure" ? (_jsxs("small", { className: "connect-preference-error", children: ["Selection memory unavailable \u00B7 ", Cause.pretty(remembered.cause)] })) : preferenceError === null ? null : (_jsxs("small", { className: "connect-preference-error", children: ["Selection memory unavailable \u00B7 ", preferenceError] })), workspaceFocusFailure === null || workspaceFocusFailure === "focus_rejected" ? null : (_jsxs("small", { className: "connect-status-message", "data-tone": "critical", children: ["Terminal focus transition failed \u00B7 ", workspaceFocusFailure] })), (current?.failures.length ?? 0) === 0 ? null : (_jsx("div", { className: "connect-failures", children: current?.failures.map((failure) => (_jsxs("small", { children: [failure.host, " \u00B7 ", failure.reason.replaceAll("_", " ")] }, failure.host))) }))] })] }));
754
+ const terminalScreen = (_jsxs(Surface, { as: "section", padding: "none", className: "terminal-stage", children: [_jsxs("div", { className: "terminal-bar", children: [_jsx("button", { className: "terminal-back", onClick: disconnect, ref: terminalBackRef, type: "button", children: "Agents" }), _jsxs("div", { children: [selected === null ? (_jsx("strong", { children: "Agent" })) : (_jsx(ConnectAgentIdentity, { agent: selected, resolution: workGoalResolution })), _jsx("small", { children: selected === null ? "Herdr terminal" : `${selected.host} · ${selected.kind}` })] }), _jsx(StateLabel, { label: connection._tag === "connected"
755
+ ? "connected"
756
+ : connection._tag === "closed"
757
+ ? "disconnected"
758
+ : "unavailable", tone: connection._tag === "connected" ? "positive" : connection._tag === "failed" ? "critical" : "neutral", size: "compact" })] }), workspaceFocusFailure === "focus_rejected" ? (_jsxs("small", { className: "connect-status-message", "data-tone": "critical", role: "alert", children: ["Terminal focus transition failed \u00B7 ", workspaceFocusFailure] })) : null, _jsx(TerminalKeyRail, { disabled: connection._tag !== "connected", error: terminalKeyError, modifier: terminalModifier, onFocusTerminal: () => terminalFocusRef.current(), onKey: sendTerminalRailKey, onModifierChange: changeTerminalModifier }), _jsx("div", { "aria-label": selected === null ? "Agent terminal" : `${selected.name} terminal`, className: "ghostty-terminal", ref: terminalRef })] }));
759
+ return (_jsxs("div", { className: embedded ? "connect-shell connect-shell-embedded" : "connect-shell", ref: attachShell, tabIndex: -1, children: [_jsx(WorkPollMount, { atom: atoms.workPoll }), _jsx(ConnectWorkspace, { directory: directoryScreen, directoryViewportRef: directoryViewportRef, mode: terminalVisible ? "terminal" : "directory", terminal: terminalScreen, terminalViewportRef: terminalViewportRef, workspaceRef: workspaceRef }), roomFooter] }));
461
760
  };
462
761
  //# sourceMappingURL=client.js.map