@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.84

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 (75) hide show
  1. package/CHANGELOG.md +893 -0
  2. package/README.md +34 -11
  3. package/dist/node/index.d.ts +205 -55
  4. package/dist/node/index.d.ts.map +1 -1
  5. package/dist/node/index.js +21 -21
  6. package/dist/node/index.js.map +1 -1
  7. package/package.json +19 -23
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +73 -23
  11. package/src/SessionPicker.tsx +27 -7
  12. package/src/SessionStatusBar.tsx +6 -2
  13. package/src/StatusBar.tsx +24 -0
  14. package/src/SupervisedSessionView.tsx +568 -161
  15. package/src/TuiInteractionChannel.ts +17 -54
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +561 -82
  18. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
  19. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
  20. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  21. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  22. package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
  23. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
  24. package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
  25. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  26. package/src/__tests__/helpers/terminal-profiles.ts +4 -4
  27. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  28. package/src/__tests__/palette-consistency.test.ts +0 -1
  29. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  30. package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
  31. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +11 -3
  32. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  33. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  34. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  35. package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
  36. package/src/__tests__/render-channel-options.test.ts +6 -6
  37. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  38. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  39. package/src/__tests__/session-picker.test.tsx +93 -0
  40. package/src/__tests__/status-bar.test.tsx +8 -0
  41. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  42. package/src/__tests__/tui-interaction-queues.test.ts +10 -0
  43. package/src/__tests__/tui-state-manager.test.ts +31 -0
  44. package/src/__tests__/wire-tui-channel.test.ts +1457 -0
  45. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  46. package/src/app-view-model.ts +6 -2
  47. package/src/attached-session-connection.ts +14 -0
  48. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  49. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  50. package/src/hooks/useAppController.ts +6 -2
  51. package/src/hooks/useAppInputBindings.ts +20 -2
  52. package/src/hooks/useAppInteractionState.ts +3 -0
  53. package/src/hooks/useAppOverlays.ts +20 -4
  54. package/src/hooks/useNumberedSelection.ts +19 -2
  55. package/src/hooks/useTuiChannel.ts +13 -1
  56. package/src/index.ts +19 -3
  57. package/src/render.tsx +218 -27
  58. package/src/short-session-id.ts +9 -0
  59. package/src/slash-command-input.ts +11 -0
  60. package/src/tui-app-channel-port.ts +29 -1
  61. package/src/tui-channel-options.ts +11 -2
  62. package/src/tui-interaction-queues.ts +9 -1
  63. package/src/tui-session-event-projector.ts +12 -3
  64. package/src/tui-session-events.ts +6 -0
  65. package/src/tui-session-options.ts +9 -0
  66. package/src/tui-state-manager.ts +25 -5
  67. package/src/types.ts +3 -1
  68. package/src/waiting-loop-stop-notice.ts +23 -0
  69. package/src/wire-history-sync.ts +159 -0
  70. package/src/wire-tui-channel.ts +963 -0
  71. package/src/wire-tui-client-commands.ts +142 -0
  72. package/src/wire-tui-projection.ts +136 -0
  73. package/dist/node/index.cjs +0 -32
  74. package/dist/node/index.d.cts +0 -1066
  75. package/dist/node/index.d.cts.map +0 -1
@@ -7,15 +7,28 @@ import SupervisedSessionView, { type ISupervisedViewRow } from '../SupervisedSes
7
7
 
8
8
  const FIRST: ISupervisedViewRow = {
9
9
  id: '8bf9bc27-d773-4e88-b88f-f7a43e9eb1f4',
10
- liveness: 'alive', control: 'available', activity: 'working',
10
+ liveness: 'alive',
11
+ control: 'available',
12
+ activity: 'working',
13
+ generation: 'firstGenerationValue01',
11
14
  };
12
15
  const SECOND: ISupervisedViewRow = {
13
16
  id: 'fe2c7f72-ecb3-4a05-9bb1-2563ec80e615',
14
- liveness: 'dead', control: 'unavailable', activity: 'unknown',
17
+ liveness: 'dead',
18
+ control: 'unavailable',
19
+ activity: 'unknown',
15
20
  };
16
21
  const THIRD: ISupervisedViewRow = {
17
22
  id: '4fa26e15-b17d-4908-afed-a156f8a8a17c',
18
- liveness: 'alive', control: 'available', activity: 'idle',
23
+ liveness: 'alive',
24
+ control: 'available',
25
+ activity: 'idle',
26
+ generation: 'thirdGenerationValue01',
27
+ };
28
+
29
+ const QUESTION = {
30
+ folder: '/work/repo',
31
+ loads: ['Project sources (metadata only; content not read):', ' [file] .robota/settings.json — settings'],
19
32
  };
20
33
 
21
34
  describe('supervised session view', () => {
@@ -28,37 +41,56 @@ describe('supervised session view', () => {
28
41
  await vi.waitFor(() => expect(view.lastFrame()).toContain('github.com #123'));
29
42
  expect(view.lastFrame()).toContain(url);
30
43
  expect(onOpenPr).not.toHaveBeenCalled();
31
- view.stdin.write('p');
32
- await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url));
33
- } finally { view.unmount(); }
44
+ view.stdin.write('o');
45
+ await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url, FIRST.generation));
46
+ } finally {
47
+ view.unmount();
48
+ }
34
49
  });
35
50
 
36
51
  it('hides stale PR links and refuses open when discovery fails', async () => {
37
52
  const url = 'https://github.com/team/repo/pull/123';
38
53
  let fail = false;
39
54
  const onOpenPr = vi.fn(async () => undefined);
40
- const view = render(<SupervisedSessionView refreshMs={20} onOpenPr={onOpenPr}
41
- loadRows={async () => {
42
- if (fail) throw new Error('offline');
43
- return [{ ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } }];
44
- }} />);
55
+ const view = render(
56
+ <SupervisedSessionView
57
+ refreshMs={20}
58
+ onOpenPr={onOpenPr}
59
+ loadRows={async () => {
60
+ if (fail) throw new Error('offline');
61
+ return [
62
+ { ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } },
63
+ ];
64
+ }}
65
+ />,
66
+ );
45
67
  try {
46
68
  await vi.waitFor(() => expect(view.lastFrame()).toContain(url));
47
69
  fail = true;
48
70
  await vi.waitFor(() => expect(view.lastFrame()).toContain('discovery unavailable'));
49
71
  expect(view.lastFrame()).not.toContain(url);
50
- view.stdin.write('p');
72
+ view.stdin.write('o');
51
73
  expect(onOpenPr).not.toHaveBeenCalled();
52
- } finally { view.unmount(); }
74
+ } finally {
75
+ view.unmount();
76
+ }
53
77
  });
54
78
 
55
79
  it('refreshes a replaced or cleared association without retaining the old URL', async () => {
56
80
  const first = 'https://github.com/team/repo/pull/123';
57
81
  const next = 'https://git.example.org/team/repo/-/merge_requests/24';
58
- let association: ISupervisedViewRow['pr'] = { url: first, host: 'github.com', number: 123, kind: 'pull' };
59
- const view = render(<SupervisedSessionView refreshMs={20} loadRows={async () => [
60
- { ...FIRST, ...(association ? { pr: association } : {}) },
61
- ]} />);
82
+ let association: ISupervisedViewRow['pr'] = {
83
+ url: first,
84
+ host: 'github.com',
85
+ number: 123,
86
+ kind: 'pull',
87
+ };
88
+ const view = render(
89
+ <SupervisedSessionView
90
+ refreshMs={20}
91
+ loadRows={async () => [{ ...FIRST, ...(association ? { pr: association } : {}) }]}
92
+ />,
93
+ );
62
94
  try {
63
95
  await vi.waitFor(() => expect(view.lastFrame()).toContain(first));
64
96
  association = { url: next, host: 'git.example.org', number: 24, kind: 'merge-request' };
@@ -66,7 +98,9 @@ describe('supervised session view', () => {
66
98
  expect(view.lastFrame()).not.toContain(first);
67
99
  association = undefined;
68
100
  await vi.waitFor(() => expect(view.lastFrame()).not.toContain(next));
69
- } finally { view.unmount(); }
101
+ } finally {
102
+ view.unmount();
103
+ }
70
104
  });
71
105
  it('toggles verified directory grouping without losing selection or inventing unknown paths', async () => {
72
106
  const rows = [
@@ -94,7 +128,9 @@ describe('supervised session view', () => {
94
128
  it('announces directory groups and their selection in screen-reader mode', async () => {
95
129
  const view = render(
96
130
  <ScreenReaderProvider enabled>
97
- <SupervisedSessionView loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]} />
131
+ <SupervisedSessionView
132
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]}
133
+ />
98
134
  </ScreenReaderProvider>,
99
135
  );
100
136
  try {
@@ -111,13 +147,19 @@ describe('supervised session view', () => {
111
147
  });
112
148
 
113
149
  it('keeps long directory headings within a narrow terminal viewport', async () => {
114
- const view = render(<SupervisedSessionView loadRows={async () => [
115
- { ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
116
- ]} />);
150
+ const view = render(
151
+ <SupervisedSessionView
152
+ loadRows={async () => [
153
+ { ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
154
+ ]}
155
+ />,
156
+ );
117
157
  try {
118
158
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
119
159
  view.stdout.emit('resize');
120
- await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`));
160
+ await vi.waitFor(() =>
161
+ expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`),
162
+ );
121
163
  view.stdin.write('g');
122
164
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
123
165
  expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
@@ -127,10 +169,14 @@ describe('supervised session view', () => {
127
169
  });
128
170
 
129
171
  it('distinguishes directory groups with a shared prefix in a narrow terminal', async () => {
130
- const view = render(<SupervisedSessionView loadRows={async () => [
131
- { ...FIRST, cwd: '/projects/alpha' },
132
- { ...THIRD, cwd: '/projects/beta' },
133
- ]} />);
172
+ const view = render(
173
+ <SupervisedSessionView
174
+ loadRows={async () => [
175
+ { ...FIRST, cwd: '/projects/alpha' },
176
+ { ...THIRD, cwd: '/projects/beta' },
177
+ ]}
178
+ />,
179
+ );
134
180
  try {
135
181
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
136
182
  view.stdout.emit('resize');
@@ -144,10 +190,14 @@ describe('supervised session view', () => {
144
190
  });
145
191
 
146
192
  it('shows differing parent directories when project basenames match at 20 columns', async () => {
147
- const view = render(<SupervisedSessionView loadRows={async () => [
148
- { ...FIRST, cwd: '/projects/alpha/app' },
149
- { ...THIRD, cwd: '/projects/beta/app' },
150
- ]} />);
193
+ const view = render(
194
+ <SupervisedSessionView
195
+ loadRows={async () => [
196
+ { ...FIRST, cwd: '/projects/alpha/app' },
197
+ { ...THIRD, cwd: '/projects/beta/app' },
198
+ ]}
199
+ />,
200
+ );
151
201
  try {
152
202
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
153
203
  view.stdout.emit('resize');
@@ -161,10 +211,14 @@ describe('supervised session view', () => {
161
211
  });
162
212
 
163
213
  it('keeps the differing path portion visible when long parent names share a prefix', async () => {
164
- const view = render(<SupervisedSessionView loadRows={async () => [
165
- { ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
166
- { ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
167
- ]} />);
214
+ const view = render(
215
+ <SupervisedSessionView
216
+ loadRows={async () => [
217
+ { ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
218
+ { ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
219
+ ]}
220
+ />,
221
+ );
168
222
  try {
169
223
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
170
224
  view.stdout.emit('resize');
@@ -178,10 +232,14 @@ describe('supervised session view', () => {
178
232
  });
179
233
 
180
234
  it('keeps the distinguishing wide character visible in a 20-column terminal', async () => {
181
- const view = render(<SupervisedSessionView loadRows={async () => [
182
- { ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
183
- { ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
184
- ]} />);
235
+ const view = render(
236
+ <SupervisedSessionView
237
+ loadRows={async () => [
238
+ { ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
239
+ { ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
240
+ ]}
241
+ />,
242
+ );
185
243
  try {
186
244
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
187
245
  view.stdout.emit('resize');
@@ -196,9 +254,11 @@ describe('supervised session view', () => {
196
254
  });
197
255
 
198
256
  it('escapes layout controls in verified directory headings', async () => {
199
- const view = render(<SupervisedSessionView loadRows={async () => [
200
- { ...FIRST, cwd: '/projects/line\nbreak/app' },
201
- ]} />);
257
+ const view = render(
258
+ <SupervisedSessionView
259
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/line\nbreak/app' }]}
260
+ />,
261
+ );
202
262
  try {
203
263
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
204
264
  view.stdin.write('g');
@@ -214,10 +274,12 @@ describe('supervised session view', () => {
214
274
  it('clears a pending screen-reader number before directory regrouping', async () => {
215
275
  const view = render(
216
276
  <ScreenReaderProvider enabled>
217
- <SupervisedSessionView loadRows={async () => [
218
- { ...FIRST, cwd: '/projects/z' },
219
- { ...THIRD, cwd: '/projects/a' },
220
- ]} />
277
+ <SupervisedSessionView
278
+ loadRows={async () => [
279
+ { ...FIRST, cwd: '/projects/z' },
280
+ { ...THIRD, cwd: '/projects/a' },
281
+ ]}
282
+ />
221
283
  </ScreenReaderProvider>,
222
284
  );
223
285
  try {
@@ -235,7 +297,9 @@ describe('supervised session view', () => {
235
297
  });
236
298
 
237
299
  it('filters by an observed group without treating dead or unverified rows as idle', async () => {
238
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />);
300
+ const view = render(
301
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />,
302
+ );
239
303
  try {
240
304
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
241
305
  expect(view.lastFrame()).toContain('State: idle');
@@ -248,14 +312,19 @@ describe('supervised session view', () => {
248
312
 
249
313
  it('shows an observed loop eligibility countdown without changing idle into a process state', async () => {
250
314
  const nextLoopAt = new Date(Date.now() + 1_200).toISOString();
251
- const view = render(<SupervisedSessionView
252
- loadRows={async () => [{ ...THIRD, nextLoopAt }]}
253
- stateFilter="idle" refreshMs={100}
254
- />);
315
+ const view = render(
316
+ <SupervisedSessionView
317
+ loadRows={async () => [{ ...THIRD, nextLoopAt }]}
318
+ stateFilter="idle"
319
+ refreshMs={100}
320
+ />,
321
+ );
255
322
  try {
256
323
  await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
257
324
  expect(view.lastFrame()).toContain('activity idle');
258
- await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), { timeout: 3_000 });
325
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
326
+ timeout: 3_000,
327
+ });
259
328
  } finally {
260
329
  view.unmount();
261
330
  }
@@ -276,8 +345,24 @@ describe('supervised session view', () => {
276
345
  }
277
346
  });
278
347
 
348
+ it('never renders loaded rows without a selection', async () => {
349
+ const view = render(
350
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
351
+ );
352
+ try {
353
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
354
+ const withRows = view.frames.filter((frame) => frame.includes('Morning review'));
355
+ expect(withRows.length).toBeGreaterThan(0);
356
+ for (const frame of withRows) expect(frame).toContain(`Selected ${THIRD.id}`);
357
+ } finally {
358
+ view.unmount();
359
+ }
360
+ });
361
+
279
362
  it('shows a verified human name while preserving the exact selected ID', async () => {
280
- const view = render(<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />);
363
+ const view = render(
364
+ <SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
365
+ );
281
366
  try {
282
367
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
283
368
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
@@ -303,10 +388,118 @@ describe('supervised session view', () => {
303
388
  }
304
389
  });
305
390
 
391
+ it('asks about a folder not trusted yet, and starts as the answer says (#3268)', async () => {
392
+ const start = vi.fn(async () => SECOND.id);
393
+ const view = render(
394
+ <SupervisedSessionView
395
+ loadRows={async () => [FIRST]}
396
+ onStart={start}
397
+ startTrustQuestion={async () => QUESTION}
398
+ />,
399
+ );
400
+ try {
401
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
402
+ view.stdin.write('n');
403
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
404
+ // It names the folder and what trusting it would load.
405
+ expect(view.lastFrame()).toContain('Not trusted: /work/repo');
406
+ expect(view.lastFrame()).toContain('.robota/settings.json');
407
+ expect(start).not.toHaveBeenCalled();
408
+ // n cancels: nothing starts.
409
+ view.stdin.write('n');
410
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
411
+ expect(start).not.toHaveBeenCalled();
412
+ view.stdin.write('n');
413
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
414
+ view.stdin.write('r');
415
+ await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('restricted'));
416
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
417
+ view.stdin.write('n');
418
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
419
+ view.stdin.write('y');
420
+ await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('trust'));
421
+ } finally {
422
+ view.unmount();
423
+ }
424
+ });
425
+
426
+ it('fits a long trust question in a 24-row terminal, the folder and the choices whole', async () => {
427
+ const loads = Array.from({ length: 32 }, (_, index) => ` [absent] source-${index} — a source`);
428
+ const view = render(
429
+ <SupervisedSessionView
430
+ loadRows={async () => [FIRST]}
431
+ onStart={async () => SECOND.id}
432
+ startTrustQuestion={async () => ({ folder: '/work/repo', loads })}
433
+ />,
434
+ );
435
+ try {
436
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
437
+ view.stdin.write('n');
438
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
439
+ const frame = view.lastFrame() ?? '';
440
+ const lines = frame.split('\n');
441
+ expect(lines.length).toBeLessThanOrEqual(24);
442
+ expect(lines).toContain('Not trusted: /work/repo');
443
+ expect(lines).toContain('y Trust and start / r Start Restricted / n Cancel');
444
+ expect(frame).toMatch(/… \d+ more — see robota trust status/);
445
+ expect(frame).toContain('source-0 — a source');
446
+ } finally {
447
+ view.unmount();
448
+ }
449
+ });
450
+
451
+ it('cancels the trust question on Escape in screen-reader mode, and keeps the view open', async () => {
452
+ const start = vi.fn(async () => SECOND.id);
453
+ const view = render(
454
+ <ScreenReaderProvider enabled>
455
+ <SupervisedSessionView
456
+ loadRows={async () => [FIRST]}
457
+ onStart={start}
458
+ startTrustQuestion={async () => QUESTION}
459
+ />
460
+ </ScreenReaderProvider>,
461
+ );
462
+ try {
463
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
464
+ view.stdin.write('n');
465
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
466
+ view.stdin.write('\u001b');
467
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
468
+ expect(view.lastFrame()).toContain(FIRST.id);
469
+ expect(start).not.toHaveBeenCalled();
470
+ } finally {
471
+ view.unmount();
472
+ }
473
+ });
474
+
475
+ it('starts at once where the folder needs no answer', async () => {
476
+ const start = vi.fn(async () => SECOND.id);
477
+ const view = render(
478
+ <SupervisedSessionView
479
+ loadRows={async () => [FIRST]}
480
+ onStart={start}
481
+ startTrustQuestion={async () => undefined}
482
+ />,
483
+ );
484
+ try {
485
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
486
+ view.stdin.write('n');
487
+ await vi.waitFor(() => expect(start).toHaveBeenCalledExactlyOnceWith(undefined));
488
+ expect(view.lastFrame()).not.toContain('r Start Restricted');
489
+ } finally {
490
+ view.unmount();
491
+ }
492
+ });
493
+
306
494
  it('keeps stop cancellation separate from starting a session', async () => {
307
495
  const start = vi.fn(async () => SECOND.id);
308
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onStart={start}
309
- onStop={async () => undefined} />);
496
+ const view = render(
497
+ <SupervisedSessionView
498
+ loadRows={async () => [FIRST]}
499
+ onStart={start}
500
+ onStop={async () => undefined}
501
+ />,
502
+ );
310
503
  try {
311
504
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
312
505
  view.stdin.write('s');
@@ -320,7 +513,9 @@ describe('supervised session view', () => {
320
513
  });
321
514
 
322
515
  it('can start from an empty view and hides private launch errors', async () => {
323
- const start = vi.fn().mockRejectedValueOnce(new Error('/private/token-path'))
516
+ const start = vi
517
+ .fn()
518
+ .mockRejectedValueOnce(new Error('/private/token-path'))
324
519
  .mockResolvedValueOnce(SECOND.id);
325
520
  const view = render(<SupervisedSessionView loadRows={async () => []} onStart={start} />);
326
521
  try {
@@ -338,10 +533,22 @@ describe('supervised session view', () => {
338
533
 
339
534
  it('does not stop a row that leaves the selected state while confirmation is open', async () => {
340
535
  let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
341
- const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => { finishRefresh = resolve; });
342
- const loadRows = vi.fn().mockResolvedValueOnce([THIRD]).mockImplementation(() => refresh);
536
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
537
+ finishRefresh = resolve;
538
+ });
539
+ const loadRows = vi
540
+ .fn()
541
+ .mockResolvedValueOnce([THIRD])
542
+ .mockImplementation(() => refresh);
343
543
  const stop = vi.fn(async () => undefined);
344
- const view = render(<SupervisedSessionView loadRows={loadRows} onStop={stop} stateFilter="idle" refreshMs={100} />);
544
+ const view = render(
545
+ <SupervisedSessionView
546
+ loadRows={loadRows}
547
+ onStop={stop}
548
+ stateFilter="idle"
549
+ refreshMs={100}
550
+ />,
551
+ );
345
552
  try {
346
553
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
347
554
  view.stdin.write('s');
@@ -359,16 +566,36 @@ describe('supervised session view', () => {
359
566
 
360
567
  it('lists every active key in help even in a narrow, crowded terminal', async () => {
361
568
  const rows = Array.from({ length: 18 }, (_, index) => ({
362
- ...FIRST, id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
569
+ ...FIRST,
570
+ id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
363
571
  }));
364
- const view = render(<SupervisedSessionView loadRows={async () => rows} onStart={async () => SECOND.id} />);
572
+ const view = render(
573
+ <SupervisedSessionView
574
+ loadRows={async () => rows}
575
+ onStart={async () => SECOND.id}
576
+ onAttach={vi.fn()}
577
+ onOpenPr={async () => undefined}
578
+ />,
579
+ );
365
580
  try {
366
581
  Object.defineProperty(view.stdout, 'columns', { value: 20 });
367
582
  view.stdout.emit('resize');
368
583
  await vi.waitFor(() => expect(view.lastFrame()).toContain('18 supervised'));
369
584
  view.stdin.write('?');
370
585
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Keys:'));
371
- for (const key of ['↑/↓ Select', 's Request stop', 'g Group state/dir', 'n New session', 'y Confirm stop', 'n/Esc Cancel stop', 'q/Esc/Ctrl+C Close', '? Toggle help']) {
586
+ for (const key of [
587
+ '↑/↓ Select',
588
+ 's Request stop',
589
+ 'g Group state/dir',
590
+ 'n New session',
591
+ 'a Attach (drive)',
592
+ 'p Peek (read only)',
593
+ 'o Open linked PR',
594
+ 'y Confirm',
595
+ 'n/Esc Cancel',
596
+ 'q/Esc/Ctrl+C Close',
597
+ '? Toggle help',
598
+ ]) {
372
599
  expect(view.lastFrame()).toContain(key);
373
600
  }
374
601
  expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
@@ -389,7 +616,9 @@ describe('supervised session view', () => {
389
616
  });
390
617
 
391
618
  it('shows a navigable global background list without inventing completion or attach', async () => {
392
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />);
619
+ const view = render(
620
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />,
621
+ );
393
622
  try {
394
623
  await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
395
624
  expect(view.lastFrame()).toContain('working');
@@ -404,7 +633,9 @@ describe('supervised session view', () => {
404
633
 
405
634
  it('never overlaps refreshes and aborts an in-flight probe when closed', async () => {
406
635
  let resolveSecond: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
407
- const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => { resolveSecond = resolve; });
636
+ const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
637
+ resolveSecond = resolve;
638
+ });
408
639
  const signals: AbortSignal[] = [];
409
640
  const loadRows = vi.fn((signal: AbortSignal) => {
410
641
  signals.push(signal);
@@ -426,18 +657,37 @@ describe('supervised session view', () => {
426
657
  });
427
658
 
428
659
  it('keeps the selected session when activity changes reorder the groups', async () => {
429
- const loadRows = vi.fn()
660
+ // The reordering refresh is released only after the selection moved, so the test cannot race
661
+ // the refresh timer against the keypress.
662
+ let releaseReorder: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
663
+ const reordered = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
664
+ releaseReorder = resolve;
665
+ });
666
+ const loadRows = vi
667
+ .fn()
430
668
  .mockResolvedValueOnce([FIRST, SECOND])
431
- .mockResolvedValue([
432
- { ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
433
- { ...SECOND, liveness: 'alive', control: 'available', activity: 'working' },
434
- ]);
435
- const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={150} />);
669
+ .mockImplementation(() => reordered);
670
+ const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
436
671
  try {
437
- await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
672
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
438
673
  view.stdin.write('\x1B[B');
439
674
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
440
675
  await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
676
+ releaseReorder?.([
677
+ { ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
678
+ { ...SECOND, liveness: 'alive', control: 'available', activity: 'working', generation: 'secondGenerationValue1' },
679
+ ]);
680
+ // Wait for the reordered list itself: SECOND listed under `working:`, FIRST under `dead:`.
681
+ const rowLine = (lines: readonly string[], id: string): number =>
682
+ lines.findIndex((line) => line.includes(id) && !line.startsWith('Selected'));
683
+ await vi.waitFor(() => {
684
+ const lines = (view.lastFrame() ?? '').split('\n');
685
+ const working = lines.indexOf('working:');
686
+ const dead = lines.indexOf('dead:');
687
+ expect(working).toBeGreaterThanOrEqual(0);
688
+ expect(rowLine(lines, SECOND.id)).toBe(working + 1);
689
+ expect(rowLine(lines, FIRST.id)).toBe(dead + 1);
690
+ });
441
691
  expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`);
442
692
  } finally {
443
693
  view.unmount();
@@ -494,7 +744,9 @@ describe('supervised session view', () => {
494
744
  ...FIRST,
495
745
  id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
496
746
  }));
497
- const view = render(<SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />);
747
+ const view = render(
748
+ <SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />,
749
+ );
498
750
  try {
499
751
  Object.defineProperty(view.stdout, 'columns', { value: 40 });
500
752
  view.stdout.emit('resize');
@@ -512,7 +764,12 @@ describe('supervised session view', () => {
512
764
 
513
765
  it('identifies the selected stop target even at 20 columns with matching UUID prefixes', async () => {
514
766
  const similar = { ...FIRST, id: `${FIRST.id.slice(0, -8)}ffffffff` };
515
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, similar]} onStop={async () => undefined} />);
767
+ const view = render(
768
+ <SupervisedSessionView
769
+ loadRows={async () => [FIRST, similar]}
770
+ onStop={async () => undefined}
771
+ />,
772
+ );
516
773
  try {
517
774
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
518
775
  view.stdin.write('\x1B[B');
@@ -530,8 +787,15 @@ describe('supervised session view', () => {
530
787
 
531
788
  it('requires confirmation and stops only the selected owner-controllable row', async () => {
532
789
  let finishStop: (() => void) | undefined;
533
- const stop = vi.fn(() => new Promise<void>((resolve) => { finishStop = resolve; }));
534
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />);
790
+ const stop = vi.fn(
791
+ () =>
792
+ new Promise<void>((resolve) => {
793
+ finishStop = resolve;
794
+ }),
795
+ );
796
+ const view = render(
797
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
798
+ );
535
799
  try {
536
800
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
537
801
  view.stdin.write('s');
@@ -543,7 +807,7 @@ describe('supervised session view', () => {
543
807
  view.stdin.write('s');
544
808
  await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
545
809
  view.stdin.write('y');
546
- await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id));
810
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id, FIRST.generation));
547
811
  await vi.waitFor(() => expect(view.lastFrame()).toContain('Stopping'));
548
812
  expect(view.lastFrame()).toContain('Stop in progress; wait for result.');
549
813
  expect(view.lastFrame()).not.toContain('q/Esc Close');
@@ -561,12 +825,17 @@ describe('supervised session view', () => {
561
825
 
562
826
  it('refuses a stop when polling removes control while confirmation is open', async () => {
563
827
  let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
564
- const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => { finishRefresh = resolve; });
565
- const loadRows = vi.fn()
828
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
829
+ finishRefresh = resolve;
830
+ });
831
+ const loadRows = vi
832
+ .fn()
566
833
  .mockResolvedValueOnce([FIRST])
567
834
  .mockImplementation(() => refresh);
568
835
  const stop = vi.fn(async () => undefined);
569
- const view = render(<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />);
836
+ const view = render(
837
+ <SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
838
+ );
570
839
  try {
571
840
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
572
841
  view.stdin.write('s');
@@ -583,8 +852,12 @@ describe('supervised session view', () => {
583
852
  });
584
853
 
585
854
  it('never offers stop for unavailable rows and leaves failures visible', async () => {
586
- const stop = vi.fn(async () => { throw new Error('private path must not be shown'); });
587
- const view = render(<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />);
855
+ const stop = vi.fn(async () => {
856
+ throw new Error('private path must not be shown');
857
+ });
858
+ const view = render(
859
+ <SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
860
+ );
588
861
  try {
589
862
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
590
863
  view.stdin.write('\x1B[B');
@@ -605,6 +878,21 @@ describe('supervised session view', () => {
605
878
  }
606
879
  });
607
880
 
881
+ it('selects when a typed number and Enter arrive in one input chunk', async () => {
882
+ const view = render(
883
+ <ScreenReaderProvider enabled>
884
+ <SupervisedSessionView loadRows={async () => [FIRST, THIRD]} />
885
+ </ScreenReaderProvider>,
886
+ );
887
+ try {
888
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
889
+ view.stdin.write('2\r');
890
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
891
+ } finally {
892
+ view.unmount();
893
+ }
894
+ });
895
+
608
896
  it('uses the same confirmed stop action after screen-reader selection', async () => {
609
897
  const stop = vi.fn(async () => undefined);
610
898
  const view = render(
@@ -621,7 +909,198 @@ describe('supervised session view', () => {
621
909
  await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stop ${THIRD.id}?`));
622
910
  expect(view.lastFrame()).not.toContain('Enter selection');
623
911
  view.stdin.write('y');
624
- await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id));
912
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id, THIRD.generation));
913
+ } finally {
914
+ view.unmount();
915
+ }
916
+ });
917
+ it('refuses a stop when the session restarts under the same id while confirmation is open', async () => {
918
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
919
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
920
+ finishRefresh = resolve;
921
+ });
922
+ const loadRows = vi
923
+ .fn()
924
+ .mockResolvedValueOnce([FIRST])
925
+ .mockImplementation(() => refresh);
926
+ const stop = vi.fn(async () => undefined);
927
+ const view = render(
928
+ <SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
929
+ );
930
+ try {
931
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
932
+ view.stdin.write('s');
933
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
934
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
935
+ finishRefresh?.([{ ...FIRST, generation: 'restartedGeneration001' }]);
936
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
937
+ view.stdin.write('y');
938
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
939
+ expect(stop).not.toHaveBeenCalled();
940
+ } finally {
941
+ view.unmount();
942
+ }
943
+ });
944
+
945
+ it('never offers stop or PR open for a row without a verified generation', async () => {
946
+ const stop = vi.fn(async () => undefined);
947
+ const onOpenPr = vi.fn(async () => undefined);
948
+ const { generation: _generation, ...unbound } = FIRST;
949
+ const row: ISupervisedViewRow = {
950
+ ...unbound,
951
+ name: 'Unbound name',
952
+ pr: { url: 'https://github.com/team/repo/pull/9', host: 'github.com', number: 9, kind: 'pull' },
953
+ };
954
+ const view = render(
955
+ <SupervisedSessionView loadRows={async () => [row]} onStop={stop} onOpenPr={onOpenPr} />,
956
+ );
957
+ try {
958
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
959
+ expect(view.lastFrame()).toContain('unverified:');
960
+ expect(view.lastFrame()).not.toContain('Unbound name');
961
+ expect(view.lastFrame()).not.toContain('github.com');
962
+ view.stdin.write('s');
963
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
964
+ expect(view.lastFrame()).not.toContain('y Yes / n No');
965
+ view.stdin.write('o');
966
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('No verified PR link'));
967
+ expect(stop).not.toHaveBeenCalled();
968
+ expect(onOpenPr).not.toHaveBeenCalled();
969
+ } finally {
970
+ view.unmount();
971
+ }
972
+ });
973
+ it('attaches or peeks at a verified row only after a confirmation bound to its generation', async () => {
974
+ for (const [key, mode, wording] of [
975
+ ['a', 'drive', 'drive (send prompts, answer its questions)'],
976
+ ['p', 'observe', 'observe (read only)'],
977
+ ] as const) {
978
+ const onAttach = vi.fn();
979
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
980
+ try {
981
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
982
+ expect(view.lastFrame()).toContain('a Attach');
983
+ expect(view.lastFrame()).toContain('p Peek');
984
+ view.stdin.write(key);
985
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(wording));
986
+ expect(view.lastFrame()).toContain('y Yes / n No');
987
+ view.stdin.write('n');
988
+ await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
989
+ expect(onAttach).not.toHaveBeenCalled();
990
+ view.stdin.write(key);
991
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
992
+ view.stdin.write('y');
993
+ await vi.waitFor(() =>
994
+ expect(onAttach).toHaveBeenCalledExactlyOnceWith({
995
+ id: FIRST.id, generation: FIRST.generation, mode, groupByDirectory: false,
996
+ }),
997
+ );
998
+ } finally {
999
+ view.unmount();
1000
+ }
1001
+ }
1002
+ });
1003
+
1004
+ it('refuses an attach when the row restarts or leaves control while confirmation is open', async () => {
1005
+ for (const replacement of [
1006
+ { ...FIRST, generation: 'restartedGeneration001' },
1007
+ { ...FIRST, control: 'unavailable' as const, activity: 'unknown' as const },
1008
+ ]) {
1009
+ let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
1010
+ const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
1011
+ finishRefresh = resolve;
1012
+ });
1013
+ const loadRows = vi.fn().mockResolvedValueOnce([FIRST]).mockImplementation(() => refresh);
1014
+ const onAttach = vi.fn();
1015
+ const view = render(<SupervisedSessionView loadRows={loadRows} onAttach={onAttach} refreshMs={100} />);
1016
+ try {
1017
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
1018
+ view.stdin.write('a');
1019
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
1020
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
1021
+ finishRefresh?.([replacement]);
1022
+ await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
1023
+ view.stdin.write('y');
1024
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
1025
+ expect(onAttach).not.toHaveBeenCalled();
1026
+ } finally {
1027
+ view.unmount();
1028
+ }
1029
+ }
1030
+ });
1031
+
1032
+ it('never offers attach or peek on dead, unknown or unverified rows', async () => {
1033
+ const { generation: _generation, ...unbound } = FIRST;
1034
+ for (const row of [
1035
+ SECOND,
1036
+ { ...FIRST, liveness: 'unknown' as const, control: 'unavailable' as const, activity: 'unknown' as const },
1037
+ unbound,
1038
+ ]) {
1039
+ const onAttach = vi.fn();
1040
+ const view = render(<SupervisedSessionView loadRows={async () => [row]} onAttach={onAttach} />);
1041
+ try {
1042
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${row.id}`));
1043
+ expect(view.lastFrame()).not.toContain('a Attach');
1044
+ expect(view.lastFrame()).not.toContain('p Peek');
1045
+ for (const key of ['a', 'p']) {
1046
+ view.stdin.write(key);
1047
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
1048
+ expect(view.lastFrame()).not.toContain('y Yes / n No');
1049
+ }
1050
+ expect(onAttach).not.toHaveBeenCalled();
1051
+ } finally {
1052
+ view.unmount();
1053
+ }
1054
+ }
1055
+ });
1056
+
1057
+ it('names the whole session id and the role in screen-reader mode', async () => {
1058
+ const onAttach = vi.fn();
1059
+ const view = render(
1060
+ <ScreenReaderProvider enabled>
1061
+ <SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />
1062
+ </ScreenReaderProvider>,
1063
+ );
1064
+ try {
1065
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
1066
+ expect(view.lastFrame()).toContain('a Attach; p Peek;');
1067
+ view.stdin.write('p');
1068
+ await vi.waitFor(() =>
1069
+ expect(view.lastFrame()).toContain(`Peek at ${FIRST.id} to observe (read only)? y Yes / n No`),
1070
+ );
1071
+ } finally {
1072
+ view.unmount();
1073
+ }
1074
+ });
1075
+ it('reopens on the row and grouping it was left with', async () => {
1076
+ const view = render(
1077
+ <SupervisedSessionView
1078
+ loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, { ...THIRD, cwd: '/projects/beta' }]}
1079
+ initialSelectedId={THIRD.id}
1080
+ initialGroupByDirectory
1081
+ />,
1082
+ );
1083
+ try {
1084
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
1085
+ expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha');
1086
+ } finally {
1087
+ view.unmount();
1088
+ }
1089
+ });
1090
+
1091
+ it('hands back the grouping with a confirmed attach', async () => {
1092
+ const onAttach = vi.fn();
1093
+ const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
1094
+ try {
1095
+ await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
1096
+ view.stdin.write('g');
1097
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Directory: unverified'));
1098
+ view.stdin.write('p');
1099
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
1100
+ view.stdin.write('y');
1101
+ await vi.waitFor(() => expect(onAttach).toHaveBeenCalledExactlyOnceWith({
1102
+ id: FIRST.id, generation: FIRST.generation, mode: 'observe', groupByDirectory: true,
1103
+ }));
625
1104
  } finally {
626
1105
  view.unmount();
627
1106
  }