github-issue-tower-defence-management 1.151.1 → 1.151.3

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 (34) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bin/adapter/entry-points/console/consoleDoneStore.js +19 -1
  3. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleOperationApi.js +17 -5
  5. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-Dihcph6j.css → index-Co7u5cAQ.css} +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/index-OB0UwU6U.js +83 -0
  8. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +21 -0
  11. package/src/adapter/entry-points/console/consoleDoneStore.ts +25 -0
  12. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +58 -7
  13. package/src/adapter/entry-points/console/consoleOperationApi.ts +43 -6
  14. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +22 -0
  15. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +10 -1
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +8 -8
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +29 -11
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +4 -4
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +10 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +4 -3
  21. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.test.ts +79 -1
  22. package/src/adapter/entry-points/console/ui/src/features/console/logic/overlay.ts +20 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +1 -1
  24. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +42 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +6 -2
  26. package/src/adapter/entry-points/console/ui/src/index.css +11 -1
  27. package/src/adapter/entry-points/console/ui-dist/assets/{index-Dihcph6j.css → index-Co7u5cAQ.css} +1 -1
  28. package/src/adapter/entry-points/console/ui-dist/assets/index-OB0UwU6U.js +83 -0
  29. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  30. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +3 -0
  31. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -1
  32. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  33. package/bin/adapter/entry-points/console/ui-dist/assets/index-UQt5L756.js +0 -83
  34. package/src/adapter/entry-points/console/ui-dist/assets/index-UQt5L756.js +0 -83
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-UQt5L756.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Dihcph6j.css">
7
+ <script type="module" crossorigin src="/assets/index-OB0UwU6U.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Co7u5cAQ.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.151.1",
3
+ "version": "1.151.3",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -3,6 +3,8 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import {
5
5
  CONSOLE_DONE_FILE_NAME,
6
+ CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES,
7
+ CONSOLE_DONE_STORY_SELECTED_TAB_NAMES,
6
8
  CONSOLE_DONE_TAB_NAMES,
7
9
  doneFilePathForTab,
8
10
  readDoneProjectItemIds,
@@ -145,4 +147,23 @@ describe('consoleDoneStore', () => {
145
147
  }
146
148
  });
147
149
  });
150
+
151
+ describe('selector partitions of the console done tabs', () => {
152
+ it('covers every console done tab exactly once across the status and story partitions', () => {
153
+ expect(
154
+ [
155
+ ...CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES,
156
+ ...CONSOLE_DONE_STORY_SELECTED_TAB_NAMES,
157
+ ].sort(),
158
+ ).toEqual([...CONSOLE_DONE_TAB_NAMES].sort());
159
+ });
160
+
161
+ it('places no console done tab in both the status and the story partition', () => {
162
+ expect(
163
+ CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES.filter((tab) =>
164
+ CONSOLE_DONE_STORY_SELECTED_TAB_NAMES.includes(tab),
165
+ ),
166
+ ).toEqual([]);
167
+ });
168
+ });
148
169
  });
@@ -89,6 +89,31 @@ export const CONSOLE_DONE_TAB_NAMES: string[] = [
89
89
  'in-tmux-by-human',
90
90
  ];
91
91
 
92
+ export const CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES: string[] = [
93
+ 'prs',
94
+ 'unread',
95
+ 'failed-preparation',
96
+ 'todo-by-human',
97
+ 'todo-by-agent',
98
+ 'in-tmux-by-human',
99
+ ];
100
+
101
+ export const CONSOLE_DONE_STORY_SELECTED_TAB_NAMES: string[] = [
102
+ 'workflow-blocker',
103
+ 'triage',
104
+ ];
105
+
106
+ export const recordDoneProjectItemIdForTabs = (
107
+ consoleDataOutputDir: string,
108
+ pjcode: string,
109
+ projectItemId: string,
110
+ tabNames: string[],
111
+ ): void => {
112
+ for (const tab of tabNames) {
113
+ recordDoneProjectItemId(consoleDataOutputDir, pjcode, tab, projectItemId);
114
+ }
115
+ };
116
+
92
117
  export const recordDoneProjectItemIdAcrossTabs = (
93
118
  consoleDataOutputDir: string,
94
119
  pjcode: string,
@@ -18,6 +18,8 @@ import {
18
18
  import {
19
19
  CONSOLE_DONE_TAB_NAMES,
20
20
  readDoneProjectItemIds,
21
+ CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES,
22
+ CONSOLE_DONE_STORY_SELECTED_TAB_NAMES,
21
23
  } from './consoleDoneStore';
22
24
 
23
25
  describe('consoleOperationApi', () => {
@@ -104,6 +106,23 @@ describe('consoleOperationApi', () => {
104
106
  }
105
107
  };
106
108
 
109
+ const expectRecordedOnlyIn = (
110
+ projectItemId: string,
111
+ recordedTabs: string[],
112
+ ): void => {
113
+ for (const tab of CONSOLE_DONE_TAB_NAMES) {
114
+ if (recordedTabs.includes(tab)) {
115
+ expect(readDoneProjectItemIds(baseDir, 'acme', tab)).toContain(
116
+ projectItemId,
117
+ );
118
+ continue;
119
+ }
120
+ expect(readDoneProjectItemIds(baseDir, 'acme', tab)).not.toContain(
121
+ projectItemId,
122
+ );
123
+ }
124
+ };
125
+
107
126
  describe('handleReview', () => {
108
127
  it('approves and sets Awaiting workspace then records done', async () => {
109
128
  const response = await handleReview(context, {
@@ -125,7 +144,7 @@ describe('consoleOperationApi', () => {
125
144
  'status_aw',
126
145
  );
127
146
  expect(issueRepository.get).not.toHaveBeenCalled();
128
- expectRecordedAcrossTabs('PVTI_a');
147
+ expectRecordedOnlyIn('PVTI_a', CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES);
129
148
  });
130
149
 
131
150
  it('resolves the issue repository from the url of the operated pull request', async () => {
@@ -173,7 +192,7 @@ describe('consoleOperationApi', () => {
173
192
  side: 'RIGHT',
174
193
  },
175
194
  );
176
- expectRecordedAcrossTabs('PVTI_b');
195
+ expectRecordedOnlyIn('PVTI_b', CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES);
177
196
  });
178
197
 
179
198
  it('requests changes without a line anchor when line and side are missing', async () => {
@@ -194,7 +213,7 @@ describe('consoleOperationApi', () => {
194
213
  'please fix',
195
214
  null,
196
215
  );
197
- expectRecordedAcrossTabs('PVTI_b');
216
+ expectRecordedOnlyIn('PVTI_b', CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES);
198
217
  });
199
218
 
200
219
  it('rejects request_changes without a comment body', async () => {
@@ -430,7 +449,37 @@ describe('consoleOperationApi', () => {
430
449
  'status_todo',
431
450
  );
432
451
  expect(issueRepository.get).not.toHaveBeenCalled();
433
- expectRecordedAcrossTabs('PVTI_d');
452
+ expectRecordedOnlyIn('PVTI_d', CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES);
453
+ });
454
+
455
+ it('keeps a status change out of the story-selected tabs', async () => {
456
+ const response = await handleTriage(context, {
457
+ pjcode: 'acme',
458
+ action: 'set_status',
459
+ issueUrl: 'https://github.com/o/r/issues/1',
460
+ projectItemId: 'PVTI_status_only',
461
+ statusName: 'Todo',
462
+ });
463
+ expect(response.statusCode).toBe(200);
464
+ expectRecordedOnlyIn(
465
+ 'PVTI_status_only',
466
+ CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES,
467
+ );
468
+ });
469
+
470
+ it('keeps a story change out of the status-selected tabs', async () => {
471
+ const response = await handleTriage(context, {
472
+ pjcode: 'acme',
473
+ action: 'set_story',
474
+ issueUrl: 'https://github.com/o/r/issues/1',
475
+ projectItemId: 'PVTI_story_only',
476
+ storyOptionId: 'story_opt_1',
477
+ });
478
+ expect(response.statusCode).toBe(200);
479
+ expectRecordedOnlyIn(
480
+ 'PVTI_story_only',
481
+ CONSOLE_DONE_STORY_SELECTED_TAB_NAMES,
482
+ );
434
483
  });
435
484
 
436
485
  it('rejects an unknown status name', async () => {
@@ -460,7 +509,7 @@ describe('consoleOperationApi', () => {
460
509
  'story_opt_1',
461
510
  );
462
511
  expect(issueRepository.get).not.toHaveBeenCalled();
463
- expectRecordedAcrossTabs('PVTI_e');
512
+ expectRecordedOnlyIn('PVTI_e', CONSOLE_DONE_STORY_SELECTED_TAB_NAMES);
464
513
  });
465
514
 
466
515
  it('snoozes for one day via updateNextActionDate', async () => {
@@ -794,7 +843,7 @@ describe('consoleOperationApi', () => {
794
843
  'status_intmux',
795
844
  );
796
845
  expect(issueRepository.get).not.toHaveBeenCalled();
797
- expectRecordedAcrossTabs('PVTI_i');
846
+ expectRecordedOnlyIn('PVTI_i', CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES);
798
847
  });
799
848
 
800
849
  it('rejects an unknown intmux action', async () => {
@@ -907,10 +956,12 @@ describe('consoleOperationApi', () => {
907
956
  expect.objectContaining({ itemId: 'PVTI_x' }),
908
957
  'status_todo',
909
958
  );
910
- for (const tab of CONSOLE_DONE_TAB_NAMES) {
959
+ for (const tab of CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES) {
911
960
  expect(readDoneProjectItemIds(baseDir, 'globex', tab)).toContain(
912
961
  'PVTI_x',
913
962
  );
963
+ }
964
+ for (const tab of CONSOLE_DONE_TAB_NAMES) {
914
965
  expect(readDoneProjectItemIds(baseDir, 'acme', tab)).not.toContain(
915
966
  'PVTI_x',
916
967
  );
@@ -5,7 +5,12 @@ import {
5
5
  import { IssueAttachmentRepository } from '../../../domain/usecases/adapter-interfaces/IssueAttachmentRepository';
6
6
  import { Project } from '../../../domain/entities/Project';
7
7
  import { Issue } from '../../../domain/entities/Issue';
8
- import { recordDoneProjectItemIdAcrossTabs } from './consoleDoneStore';
8
+ import {
9
+ recordDoneProjectItemIdAcrossTabs,
10
+ recordDoneProjectItemIdForTabs,
11
+ CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES,
12
+ CONSOLE_DONE_STORY_SELECTED_TAB_NAMES,
13
+ } from './consoleDoneStore';
9
14
  import { findConsoleItemUrl } from './consoleItemUrlLookup';
10
15
 
11
16
  export const AWAITING_WORKSPACE_STATUS_NAME = 'awaiting workspace';
@@ -135,6 +140,38 @@ const recordDone = (
135
140
  );
136
141
  };
137
142
 
143
+ const recordDoneForStatusChange = (
144
+ context: ConsoleOperationContext,
145
+ pjcode: string,
146
+ projectItemId: string,
147
+ ): void => {
148
+ if (context.consoleDataOutputDir === null) {
149
+ return;
150
+ }
151
+ recordDoneProjectItemIdForTabs(
152
+ context.consoleDataOutputDir,
153
+ pjcode,
154
+ projectItemId,
155
+ CONSOLE_DONE_STATUS_SELECTED_TAB_NAMES,
156
+ );
157
+ };
158
+
159
+ const recordDoneForStoryChange = (
160
+ context: ConsoleOperationContext,
161
+ pjcode: string,
162
+ projectItemId: string,
163
+ ): void => {
164
+ if (context.consoleDataOutputDir === null) {
165
+ return;
166
+ }
167
+ recordDoneProjectItemIdForTabs(
168
+ context.consoleDataOutputDir,
169
+ pjcode,
170
+ projectItemId,
171
+ CONSOLE_DONE_STORY_SELECTED_TAB_NAMES,
172
+ );
173
+ };
174
+
138
175
  const resolveBinding = async (
139
176
  context: ConsoleOperationContext,
140
177
  body: Record<string, unknown>,
@@ -298,7 +335,7 @@ export const handleReview = async (
298
335
  if (failure !== null) {
299
336
  return failure;
300
337
  }
301
- recordDone(context, pjcode, projectItemId);
338
+ recordDoneForStatusChange(context, pjcode, projectItemId);
302
339
  return ok();
303
340
  }
304
341
 
@@ -334,7 +371,7 @@ export const handleReview = async (
334
371
  if (failure !== null) {
335
372
  return failure;
336
373
  }
337
- recordDone(context, pjcode, projectItemId);
374
+ recordDoneForStatusChange(context, pjcode, projectItemId);
338
375
  return ok();
339
376
  }
340
377
 
@@ -403,7 +440,7 @@ export const handleTriage = async (
403
440
  if (failure !== null) {
404
441
  return failure;
405
442
  }
406
- recordDone(context, pjcode, projectItemId);
443
+ recordDoneForStatusChange(context, pjcode, projectItemId);
407
444
  return ok();
408
445
  }
409
446
 
@@ -422,7 +459,7 @@ export const handleTriage = async (
422
459
  projectItemReference(issueUrl, projectItemId),
423
460
  storyOptionId,
424
461
  );
425
- recordDone(context, pjcode, projectItemId);
462
+ recordDoneForStoryChange(context, pjcode, projectItemId);
426
463
  return ok();
427
464
  }
428
465
 
@@ -590,6 +627,6 @@ export const handleIntmux = async (
590
627
  if (failure !== null) {
591
628
  return failure;
592
629
  }
593
- recordDone(context, pjcode, projectItemId);
630
+ recordDoneForStatusChange(context, pjcode, projectItemId);
594
631
  return ok();
595
632
  };
@@ -274,3 +274,25 @@ test('lists a still-open item and keeps its tab visible when the browser overlay
274
274
  ),
275
275
  ).toBeVisible();
276
276
  });
277
+
278
+ test('keeps the comment control on screen while the item body scrolls, and gives the height back when it is closed', async ({
279
+ page,
280
+ }) => {
281
+ await page.goto(harness.appRootUrl);
282
+
283
+ await itemRowByText(
284
+ page,
285
+ 'Resolve the shared GitHub token rate-limit exhaustion blocker',
286
+ ).click();
287
+
288
+ const composerToggle = page.locator('.console-composer-toggle');
289
+ await expect(composerToggle).toBeInViewport();
290
+ await expect(page.locator('.console-composer-input')).toHaveCount(0);
291
+
292
+ await composerToggle.click();
293
+ await expect(page.locator('.console-composer-input')).toBeInViewport();
294
+
295
+ await composerToggle.click();
296
+ await expect(page.locator('.console-composer-input')).toHaveCount(0);
297
+ await expect(composerToggle).toBeInViewport();
298
+ });
@@ -462,7 +462,16 @@ const createStubIssueRepository = (
462
462
  addIssueToProject: () => notImplemented('addIssueToProject'),
463
463
  setDependedIssueUrl: () => notImplemented('setDependedIssueUrl'),
464
464
  getIssueOrPullRequestBody: async (): Promise<string> =>
465
- '## Console E2E fixture\n\nThis body is served by the isolated E2E stub.',
465
+ [
466
+ '## Console E2E fixture',
467
+ '',
468
+ 'This body is served by the isolated E2E stub.',
469
+ '',
470
+ ...Array.from(
471
+ { length: 80 },
472
+ (_, index) => `Description line ${index + 1} of the fixture body.`,
473
+ ),
474
+ ].join('\n'),
466
475
  getIssueOrPullRequestComments: async (): Promise<IssueComment[]> => [],
467
476
  getPullRequestDetail: async (
468
477
  url: string,
@@ -21,25 +21,25 @@ export default meta;
21
21
 
22
22
  type Story = StoryObj<typeof ConsoleCommentComposer>;
23
23
 
24
- export const IssueComposerOpen: Story = {
25
- args: { isPr: false },
24
+ export const ComposerOpen: Story = {
25
+ args: { initiallyOpen: true },
26
26
  };
27
27
 
28
- export const PullRequestComposerCollapsed: Story = {
29
- args: { isPr: true },
28
+ export const ComposerCollapsed: Story = {
29
+ args: { initiallyOpen: false },
30
30
  };
31
31
 
32
- export const IssueComposerWithFileUpload: Story = {
32
+ export const ComposerWithFileUpload: Story = {
33
33
  args: {
34
- isPr: false,
34
+ initiallyOpen: true,
35
35
  onUploadFile: async (file: File) =>
36
36
  `![${file.name}](https://github.com/user-attachments/assets/9f0b1c3d-4e5f-4a6b-8c7d-1e2f3a4b5c6d)`,
37
37
  },
38
38
  };
39
39
 
40
- export const IssueComposerUploadFailure: Story = {
40
+ export const ComposerUploadFailure: Story = {
41
41
  args: {
42
- isPr: false,
42
+ initiallyOpen: true,
43
43
  onUploadFile: async () => {
44
44
  throw new Error('No GitHub web session is available');
45
45
  },
@@ -12,10 +12,10 @@ jest.mock('../../lib/mermaidLoader', () => ({
12
12
  const now = Date.parse('2026-06-19T12:00:00.000Z');
13
13
 
14
14
  describe('ConsoleCommentComposer', () => {
15
- it('opens the form by default for an issue item', () => {
15
+ it('shows the form when it starts open', () => {
16
16
  const { getByPlaceholderText } = render(
17
17
  <ConsoleCommentComposer
18
- isPr={false}
18
+ initiallyOpen
19
19
  now={now}
20
20
  onSubmit={async (body) => ({
21
21
  author: 'HiromiShikata',
@@ -27,10 +27,10 @@ describe('ConsoleCommentComposer', () => {
27
27
  expect(getByPlaceholderText('Leave a comment…')).toBeInTheDocument();
28
28
  });
29
29
 
30
- it('keeps the form closed by default for a pull request item', () => {
30
+ it('offers the opening control and hides the form when it starts closed', () => {
31
31
  const { queryByPlaceholderText, getByText } = render(
32
32
  <ConsoleCommentComposer
33
- isPr
33
+ initiallyOpen={false}
34
34
  now={now}
35
35
  onSubmit={async (body) => ({
36
36
  author: 'HiromiShikata',
@@ -43,6 +43,24 @@ describe('ConsoleCommentComposer', () => {
43
43
  expect(getByText('💬 Add a comment')).toBeInTheDocument();
44
44
  });
45
45
 
46
+ it('opens the form on the control and closes it again so the body regains the height', () => {
47
+ const { getByText, getByPlaceholderText, queryByPlaceholderText } = render(
48
+ <ConsoleCommentComposer
49
+ initiallyOpen={false}
50
+ now={now}
51
+ onSubmit={async (body) => ({
52
+ author: 'HiromiShikata',
53
+ body,
54
+ createdAt: '2026-06-19T11:58:00.000Z',
55
+ })}
56
+ />,
57
+ );
58
+ fireEvent.click(getByText('💬 Add a comment'));
59
+ expect(getByPlaceholderText('Leave a comment…')).toBeInTheDocument();
60
+ fireEvent.click(getByText('✕ Close'));
61
+ expect(queryByPlaceholderText('Leave a comment…')).toBeNull();
62
+ });
63
+
46
64
  it('submits the comment and shows the posted comment', async () => {
47
65
  const onSubmit = jest.fn(
48
66
  async (body: string): Promise<ConsoleComment> => ({
@@ -52,7 +70,7 @@ describe('ConsoleCommentComposer', () => {
52
70
  }),
53
71
  );
54
72
  const { getByPlaceholderText, getByText } = render(
55
- <ConsoleCommentComposer isPr={false} now={now} onSubmit={onSubmit} />,
73
+ <ConsoleCommentComposer initiallyOpen now={now} onSubmit={onSubmit} />,
56
74
  );
57
75
  fireEvent.change(getByPlaceholderText('Leave a comment…'), {
58
76
  target: { value: 'Looks good after the rebase.' },
@@ -67,7 +85,7 @@ describe('ConsoleCommentComposer', () => {
67
85
  it('shows a failure message when the submission rejects', async () => {
68
86
  const { getByPlaceholderText, getByText, findByRole } = render(
69
87
  <ConsoleCommentComposer
70
- isPr={false}
88
+ initiallyOpen
71
89
  now={now}
72
90
  onSubmit={async () => {
73
91
  throw new Error('HTTP 500');
@@ -85,7 +103,7 @@ describe('ConsoleCommentComposer', () => {
85
103
  it('does not render the attach control when no upload handler is given', () => {
86
104
  const { queryByText } = render(
87
105
  <ConsoleCommentComposer
88
- isPr={false}
106
+ initiallyOpen
89
107
  now={now}
90
108
  onSubmit={async (body) => ({
91
109
  author: 'HiromiShikata',
@@ -103,7 +121,7 @@ describe('ConsoleCommentComposer', () => {
103
121
  );
104
122
  const { getByPlaceholderText, getByLabelText } = render(
105
123
  <ConsoleCommentComposer
106
- isPr={false}
124
+ initiallyOpen
107
125
  now={now}
108
126
  onSubmit={async (body) => ({
109
127
  author: 'HiromiShikata',
@@ -135,7 +153,7 @@ describe('ConsoleCommentComposer', () => {
135
153
  );
136
154
  const { getByPlaceholderText } = render(
137
155
  <ConsoleCommentComposer
138
- isPr={false}
156
+ initiallyOpen
139
157
  now={now}
140
158
  onSubmit={async (body) => ({
141
159
  author: 'HiromiShikata',
@@ -164,7 +182,7 @@ describe('ConsoleCommentComposer', () => {
164
182
  );
165
183
  const { getByPlaceholderText } = render(
166
184
  <ConsoleCommentComposer
167
- isPr={false}
185
+ initiallyOpen
168
186
  now={now}
169
187
  onSubmit={async (body) => ({
170
188
  author: 'HiromiShikata',
@@ -190,7 +208,7 @@ describe('ConsoleCommentComposer', () => {
190
208
  it('shows the upload failure reason when the upload rejects', async () => {
191
209
  const { getByLabelText, findByRole } = render(
192
210
  <ConsoleCommentComposer
193
- isPr={false}
211
+ initiallyOpen
194
212
  now={now}
195
213
  onSubmit={async (body) => ({
196
214
  author: 'HiromiShikata',
@@ -7,7 +7,7 @@ import type { ConsoleComment } from '../../logic/types';
7
7
  import { ConsoleMarkdownContent } from '../content/ConsoleMarkdownContent';
8
8
 
9
9
  export type ConsoleCommentComposerProps = {
10
- isPr: boolean;
10
+ initiallyOpen: boolean;
11
11
  now: number;
12
12
  onSubmit: (body: string) => Promise<ConsoleComment>;
13
13
  onUploadFile?: (file: File) => Promise<string>;
@@ -37,12 +37,12 @@ export const appendAttachmentMarkdown = (
37
37
  };
38
38
 
39
39
  export const ConsoleCommentComposer = ({
40
- isPr,
40
+ initiallyOpen,
41
41
  now,
42
42
  onSubmit,
43
43
  onUploadFile,
44
44
  }: ConsoleCommentComposerProps) => {
45
- const [open, setOpen] = useState<boolean>(!isPr);
45
+ const [open, setOpen] = useState<boolean>(initiallyOpen);
46
46
  const [draft, setDraft] = useState<string>('');
47
47
  const [status, setStatus] = useState<ComposerStatus>({ kind: 'idle' });
48
48
  const [uploadStatus, setUploadStatus] = useState<UploadStatus>({
@@ -99,7 +99,7 @@ export const ConsoleCommentComposer = ({
99
99
  aria-expanded={open}
100
100
  onClick={() => setOpen((value) => !value)}
101
101
  >
102
- {open ? '✕ Cancel' : '💬 Add a comment'}
102
+ {open ? '✕ Close' : '💬 Add a comment'}
103
103
  </button>
104
104
  {posted.length > 0 && (
105
105
  <div className="console-composer-posted">
@@ -44,6 +44,16 @@ const baseProps = {
44
44
  };
45
45
 
46
46
  describe('ConsoleItemDetail', () => {
47
+ it('docks the comment composer with the operation bar so it stays reachable while the body scrolls', () => {
48
+ const { container, getByText } = render(
49
+ <ConsoleItemDetail item={issueItem} {...baseProps} />,
50
+ );
51
+ const dock = container.querySelector('.console-detail-dock');
52
+ expect(dock).not.toBeNull();
53
+ expect(dock).toContainElement(getByText('comment-composer'));
54
+ expect(dock).toContainElement(getByText('operation-bar'));
55
+ });
56
+
47
57
  it('reports a failed related pull request read on an issue item', () => {
48
58
  const { getByRole } = render(
49
59
  <ConsoleItemDetail
@@ -327,9 +327,10 @@ export const ConsoleItemDetail = ({
327
327
  />
328
328
  ))}
329
329
 
330
- {commentComposer}
331
-
332
- <div className="console-actionbar">{operationBar}</div>
330
+ <div className="console-detail-dock">
331
+ <div className="console-detail-dock-composer">{commentComposer}</div>
332
+ <div className="console-actionbar">{operationBar}</div>
333
+ </div>
333
334
  </article>
334
335
  );
335
336
  };
@@ -4,10 +4,15 @@ import {
4
4
  isOverlayEntryActed,
5
5
  overlayEntriesActedSinceSnapshot,
6
6
  overlayKeyForItem,
7
+ overlayStatusSinceSnapshot,
7
8
  overlayStorageKey,
8
9
  writeOverlayEntry,
9
10
  } from './overlay';
10
- import type { ConsoleListItem, ConsoleOverlay } from './types';
11
+ import type {
12
+ ConsoleListItem,
13
+ ConsoleOverlay,
14
+ ConsoleOverlayStatus,
15
+ } from './types';
11
16
 
12
17
  const item = (number: number): ConsoleListItem => ({
13
18
  number,
@@ -230,3 +235,76 @@ describe('writeOverlayEntry', () => {
230
235
  });
231
236
  });
232
237
  });
238
+
239
+ describe('overlayStatusSinceSnapshot', () => {
240
+ const snapshotGeneratedAt = '2026-08-13T21:52:01.000Z';
241
+ const snapshotEpochMs = Date.parse(snapshotGeneratedAt);
242
+ const inTmuxByHuman: ConsoleOverlayStatus = {
243
+ name: 'In Tmux by human',
244
+ color: 'RED',
245
+ };
246
+
247
+ const overlayWithStatusAt = (epochMs: number): ConsoleOverlay => ({
248
+ [overlayKeyForItem(item(1))]: {
249
+ status: inTmuxByHuman,
250
+ ts: epochMs,
251
+ mode: 'todo-by-human',
252
+ },
253
+ });
254
+
255
+ it('shows a status the owner set after the snapshot was generated', () => {
256
+ expect(
257
+ overlayStatusSinceSnapshot(
258
+ overlayWithStatusAt(snapshotEpochMs + 1000),
259
+ item(1),
260
+ snapshotGeneratedAt,
261
+ ),
262
+ ).toEqual(inTmuxByHuman);
263
+ });
264
+
265
+ it('drops a status the snapshot already superseded', () => {
266
+ expect(
267
+ overlayStatusSinceSnapshot(
268
+ overlayWithStatusAt(snapshotEpochMs - 1000),
269
+ item(1),
270
+ snapshotGeneratedAt,
271
+ ),
272
+ ).toBeNull();
273
+ });
274
+
275
+ it('drops every status when the snapshot time is unusable', () => {
276
+ expect(
277
+ overlayStatusSinceSnapshot(
278
+ overlayWithStatusAt(snapshotEpochMs + 1000),
279
+ item(1),
280
+ null,
281
+ ),
282
+ ).toBeNull();
283
+ });
284
+
285
+ it('shows a status set at the exact moment the snapshot was generated', () => {
286
+ expect(
287
+ overlayStatusSinceSnapshot(
288
+ overlayWithStatusAt(snapshotEpochMs),
289
+ item(1),
290
+ snapshotGeneratedAt,
291
+ ),
292
+ ).toEqual(inTmuxByHuman);
293
+ });
294
+
295
+ it('drops every status when the snapshot time cannot be parsed', () => {
296
+ expect(
297
+ overlayStatusSinceSnapshot(
298
+ overlayWithStatusAt(snapshotEpochMs + 1000),
299
+ item(1),
300
+ 'not a timestamp',
301
+ ),
302
+ ).toBeNull();
303
+ });
304
+
305
+ it('returns null when the item has no overlay entry', () => {
306
+ expect(
307
+ overlayStatusSinceSnapshot({}, item(1), snapshotGeneratedAt),
308
+ ).toBeNull();
309
+ });
310
+ });