github-issue-tower-defence-management 1.91.2 → 1.91.4

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 (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/consoleOperationApi.js +28 -1
  3. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleServer.js +2 -0
  5. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BU6p3cGU.css → index-0IuY3q4G.css} +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/index-D04N09aG.js +100 -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/consoleOperationApi.test.ts +72 -0
  11. package/src/adapter/entry-points/console/consoleOperationApi.ts +32 -0
  12. package/src/adapter/entry-points/console/consoleServer.test.ts +49 -0
  13. package/src/adapter/entry-points/console/consoleServer.ts +3 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.stories.tsx +30 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.test.tsx +81 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentComposer.tsx +118 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +1 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +20 -3
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +31 -5
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsolePanel.stories.tsx +25 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsolePanel.test.tsx +27 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsolePanel.tsx +4 -1
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +18 -14
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +40 -13
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +67 -29
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.stories.tsx +1 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.test.tsx +7 -1
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemList.tsx +4 -1
  29. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.stories.tsx +1 -1
  30. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.test.tsx +43 -6
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleItemSummary.tsx +26 -3
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleNextActionDateActions.tsx +2 -2
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsolePullRequestReviewActions.tsx +7 -8
  34. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleNavigation.test.ts +76 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleNavigation.ts +111 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.test.ts +37 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +18 -0
  38. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +29 -1
  39. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +40 -6
  40. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +5 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +8 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +26 -9
  43. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +21 -20
  44. package/src/adapter/entry-points/console/ui/src/index.css +241 -39
  45. package/src/adapter/entry-points/console/ui-dist/assets/{index-BU6p3cGU.css → index-0IuY3q4G.css} +1 -1
  46. package/src/adapter/entry-points/console/ui-dist/assets/index-D04N09aG.js +100 -0
  47. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  48. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -0
  49. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  50. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  51. package/bin/adapter/entry-points/console/ui-dist/assets/index-BvuSQN9s.js +0 -100
  52. package/src/adapter/entry-points/console/ui-dist/assets/index-Druih-WG.js +0 -100
@@ -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-BvuSQN9s.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-BU6p3cGU.css">
7
+ <script type="module" crossorigin src="/assets/index-D04N09aG.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-0IuY3q4G.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.91.2",
3
+ "version": "1.91.4",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -7,6 +7,7 @@ import { Project } from '../../../domain/entities/Project';
7
7
  import { Issue } from '../../../domain/entities/Issue';
8
8
  import {
9
9
  ConsoleOperationContext,
10
+ handleComment,
10
11
  handleIntmux,
11
12
  handleReview,
12
13
  handleTriage,
@@ -555,4 +556,75 @@ describe('consoleOperationApi', () => {
555
556
  expect(response.statusCode).toBe(200);
556
557
  });
557
558
  });
559
+
560
+ describe('handleComment', () => {
561
+ it('posts a comment and returns the created comment', async () => {
562
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([
563
+ {
564
+ author: 'github-actions',
565
+ body: 'All required checks have passed.',
566
+ createdAt: new Date('2026-06-17T07:48:11.000Z'),
567
+ },
568
+ {
569
+ author: 'HiromiShikata',
570
+ body: 'Please rebase onto the latest main branch.',
571
+ createdAt: new Date('2026-06-17T09:03:27.000Z'),
572
+ },
573
+ ]);
574
+ const response = await handleComment(context, {
575
+ pjcode: 'umino',
576
+ url: 'https://github.com/o/r/issues/1',
577
+ body: 'Please rebase onto the latest main branch.',
578
+ });
579
+ expect(response.statusCode).toBe(200);
580
+ expect(issueRepository.createCommentByUrl).toHaveBeenCalledWith(
581
+ 'https://github.com/o/r/issues/1',
582
+ 'Please rebase onto the latest main branch.',
583
+ );
584
+ expect(response.body).toEqual({
585
+ ok: true,
586
+ comment: {
587
+ author: 'HiromiShikata',
588
+ body: 'Please rebase onto the latest main branch.',
589
+ createdAt: '2026-06-17T09:03:27.000Z',
590
+ },
591
+ });
592
+ });
593
+
594
+ it('falls back to the posted body when no comment is returned', async () => {
595
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([]);
596
+ const response = await handleComment(context, {
597
+ pjcode: 'umino',
598
+ url: 'https://github.com/o/r/issues/1',
599
+ body: 'A first comment on this issue.',
600
+ });
601
+ expect(response.statusCode).toBe(200);
602
+ expect(response.body).toEqual({
603
+ ok: true,
604
+ comment: {
605
+ author: '',
606
+ body: 'A first comment on this issue.',
607
+ createdAt: '',
608
+ },
609
+ });
610
+ });
611
+
612
+ it('rejects when url is missing', async () => {
613
+ const response = await handleComment(context, {
614
+ pjcode: 'umino',
615
+ body: 'A comment without a target url.',
616
+ });
617
+ expect(response.statusCode).toBe(400);
618
+ expect(issueRepository.createCommentByUrl).not.toHaveBeenCalled();
619
+ });
620
+
621
+ it('rejects when body is missing', async () => {
622
+ const response = await handleComment(context, {
623
+ pjcode: 'umino',
624
+ url: 'https://github.com/o/r/issues/1',
625
+ });
626
+ expect(response.statusCode).toBe(400);
627
+ expect(issueRepository.createCommentByUrl).not.toHaveBeenCalled();
628
+ });
629
+ });
558
630
  });
@@ -296,6 +296,38 @@ export const handleTriage = async (
296
296
  return badRequest(`unknown triage action "${action}"`);
297
297
  };
298
298
 
299
+ export const handleComment = async (
300
+ context: ConsoleOperationContext,
301
+ body: Record<string, unknown>,
302
+ ): Promise<ConsoleOperationResponse> => {
303
+ const url = body.url;
304
+ const commentBody = body.body;
305
+ if (!isNonEmptyString(url)) {
306
+ return badRequest('url is required');
307
+ }
308
+ if (!isNonEmptyString(commentBody)) {
309
+ return badRequest('body is required');
310
+ }
311
+ await context.issueRepository.createCommentByUrl(url, commentBody);
312
+ const comments =
313
+ await context.issueRepository.getIssueOrPullRequestComments(url);
314
+ const posted = comments[comments.length - 1] ?? null;
315
+ return {
316
+ statusCode: 200,
317
+ body: {
318
+ ok: true,
319
+ comment:
320
+ posted === null
321
+ ? { author: '', body: commentBody, createdAt: '' }
322
+ : {
323
+ author: posted.author,
324
+ body: posted.body,
325
+ createdAt: posted.createdAt.toISOString(),
326
+ },
327
+ },
328
+ };
329
+ };
330
+
299
331
  export const handleIntmux = async (
300
332
  context: ConsoleOperationContext,
301
333
  body: Record<string, unknown>,
@@ -533,6 +533,55 @@ describe('consoleServer new routes integration', () => {
533
533
  }
534
534
  });
535
535
 
536
+ it('posts a comment through the comment operation api', async () => {
537
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
538
+ const issueRepository = mock<IssueRepository>();
539
+ issueRepository.getIssueOrPullRequestComments.mockResolvedValue([
540
+ {
541
+ author: 'HiromiShikata',
542
+ body: 'Thanks, this resolves the parity gap.',
543
+ createdAt: new Date('2026-06-18T03:21:00.000Z'),
544
+ },
545
+ ]);
546
+ const server = await startConsoleServer({
547
+ accessToken: testToken,
548
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
549
+ consoleDataOutputDir: null,
550
+ issueRepository,
551
+ resolveProject: async (pjcode) =>
552
+ pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
553
+ port: 0,
554
+ });
555
+ try {
556
+ const response = await request(
557
+ server,
558
+ 'POST',
559
+ `/api/comment?k=${testToken}`,
560
+ {
561
+ pjcode: 'umino',
562
+ url: 'https://github.com/o/r/issues/1',
563
+ body: 'Thanks, this resolves the parity gap.',
564
+ },
565
+ );
566
+ expect(response.statusCode).toBe(200);
567
+ expect(issueRepository.createCommentByUrl).toHaveBeenCalledWith(
568
+ 'https://github.com/o/r/issues/1',
569
+ 'Thanks, this resolves the parity gap.',
570
+ );
571
+ expect(JSON.parse(response.body)).toEqual({
572
+ ok: true,
573
+ comment: {
574
+ author: 'HiromiShikata',
575
+ body: 'Thanks, this resolves the parity gap.',
576
+ createdAt: '2026-06-18T03:21:00.000Z',
577
+ },
578
+ });
579
+ } finally {
580
+ await closeServer(server);
581
+ fs.rmSync(tmpDir, { recursive: true, force: true });
582
+ }
583
+ });
584
+
536
585
  it('runs an operation api and records the done exclusion', async () => {
537
586
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
538
587
  const dataDir = path.join(tmpDir, 'data');
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  ConsoleOperationContext,
21
21
  ConsoleProjectResolver,
22
+ handleComment,
22
23
  handleIntmux,
23
24
  handleReview,
24
25
  handleTriage,
@@ -306,6 +307,8 @@ const handleOperationApi = async (
306
307
  return handleTriage(context, body);
307
308
  case '/api/intmux':
308
309
  return handleIntmux(context, body);
310
+ case '/api/comment':
311
+ return handleComment(context, body);
309
312
  default:
310
313
  return null;
311
314
  }
@@ -0,0 +1,30 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import type { ConsoleComment } from '../../logic/types';
3
+ import { ConsoleCommentComposer } from './ConsoleCommentComposer';
4
+
5
+ const acceptComment = async (body: string): Promise<ConsoleComment> => ({
6
+ author: 'HiromiShikata',
7
+ body,
8
+ createdAt: '2026-06-19T11:58:00.000Z',
9
+ });
10
+
11
+ const meta: Meta<typeof ConsoleCommentComposer> = {
12
+ title: 'Console/ConsoleCommentComposer',
13
+ component: ConsoleCommentComposer,
14
+ args: {
15
+ now: Date.parse('2026-06-19T12:00:00.000Z'),
16
+ onSubmit: acceptComment,
17
+ },
18
+ };
19
+
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof ConsoleCommentComposer>;
23
+
24
+ export const IssueComposerOpen: Story = {
25
+ args: { isPr: false },
26
+ };
27
+
28
+ export const PullRequestComposerCollapsed: Story = {
29
+ args: { isPr: true },
30
+ };
@@ -0,0 +1,81 @@
1
+ import { fireEvent, render, waitFor } from '@testing-library/react';
2
+ import type { ConsoleComment } from '../../logic/types';
3
+ import { ConsoleCommentComposer } from './ConsoleCommentComposer';
4
+
5
+ jest.mock('../../lib/mermaidLoader', () => ({
6
+ renderMermaidToSvg: jest.fn(async () => '<svg></svg>'),
7
+ }));
8
+
9
+ const now = Date.parse('2026-06-19T12:00:00.000Z');
10
+
11
+ describe('ConsoleCommentComposer', () => {
12
+ it('opens the form by default for an issue item', () => {
13
+ const { getByPlaceholderText } = render(
14
+ <ConsoleCommentComposer
15
+ isPr={false}
16
+ now={now}
17
+ onSubmit={async (body) => ({
18
+ author: 'HiromiShikata',
19
+ body,
20
+ createdAt: '2026-06-19T11:58:00.000Z',
21
+ })}
22
+ />,
23
+ );
24
+ expect(getByPlaceholderText('Leave a comment…')).toBeInTheDocument();
25
+ });
26
+
27
+ it('keeps the form closed by default for a pull request item', () => {
28
+ const { queryByPlaceholderText, getByText } = render(
29
+ <ConsoleCommentComposer
30
+ isPr
31
+ now={now}
32
+ onSubmit={async (body) => ({
33
+ author: 'HiromiShikata',
34
+ body,
35
+ createdAt: '2026-06-19T11:58:00.000Z',
36
+ })}
37
+ />,
38
+ );
39
+ expect(queryByPlaceholderText('Leave a comment…')).toBeNull();
40
+ expect(getByText('💬 Add a comment')).toBeInTheDocument();
41
+ });
42
+
43
+ it('submits the comment and shows the posted comment', async () => {
44
+ const onSubmit = jest.fn(
45
+ async (body: string): Promise<ConsoleComment> => ({
46
+ author: 'HiromiShikata',
47
+ body,
48
+ createdAt: '2026-06-19T11:58:00.000Z',
49
+ }),
50
+ );
51
+ const { getByPlaceholderText, getByText } = render(
52
+ <ConsoleCommentComposer isPr={false} now={now} onSubmit={onSubmit} />,
53
+ );
54
+ fireEvent.change(getByPlaceholderText('Leave a comment…'), {
55
+ target: { value: 'Looks good after the rebase.' },
56
+ });
57
+ fireEvent.click(getByText('Comment'));
58
+ await waitFor(() => {
59
+ expect(getByText('Looks good after the rebase.')).toBeInTheDocument();
60
+ });
61
+ expect(onSubmit).toHaveBeenCalledWith('Looks good after the rebase.');
62
+ });
63
+
64
+ it('shows a failure message when the submission rejects', async () => {
65
+ const { getByPlaceholderText, getByText, findByRole } = render(
66
+ <ConsoleCommentComposer
67
+ isPr={false}
68
+ now={now}
69
+ onSubmit={async () => {
70
+ throw new Error('HTTP 500');
71
+ }}
72
+ />,
73
+ );
74
+ fireEvent.change(getByPlaceholderText('Leave a comment…'), {
75
+ target: { value: 'This should fail to post.' },
76
+ });
77
+ fireEvent.click(getByText('Comment'));
78
+ const alert = await findByRole('alert');
79
+ expect(alert.textContent).toContain('HTTP 500');
80
+ });
81
+ });
@@ -0,0 +1,118 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ formatFullTimestamp,
4
+ formatRelativeTime,
5
+ } from '../../logic/relativeTime';
6
+ import type { ConsoleComment } from '../../logic/types';
7
+ import { ConsoleMarkdownContent } from '../content/ConsoleMarkdownContent';
8
+
9
+ export type ConsoleCommentComposerProps = {
10
+ isPr: boolean;
11
+ now: number;
12
+ onSubmit: (body: string) => Promise<ConsoleComment>;
13
+ };
14
+
15
+ type ComposerStatus =
16
+ | { kind: 'idle' }
17
+ | { kind: 'posting' }
18
+ | { kind: 'error'; message: string };
19
+
20
+ export const ConsoleCommentComposer = ({
21
+ isPr,
22
+ now,
23
+ onSubmit,
24
+ }: ConsoleCommentComposerProps) => {
25
+ const [open, setOpen] = useState<boolean>(!isPr);
26
+ const [draft, setDraft] = useState<string>('');
27
+ const [status, setStatus] = useState<ComposerStatus>({ kind: 'idle' });
28
+ const [posted, setPosted] = useState<ConsoleComment[]>([]);
29
+
30
+ const submit = async (): Promise<void> => {
31
+ const body = draft.trim();
32
+ if (body.length === 0 || status.kind === 'posting') {
33
+ return;
34
+ }
35
+ setStatus({ kind: 'posting' });
36
+ try {
37
+ const comment = await onSubmit(body);
38
+ setPosted((previous) => [...previous, comment]);
39
+ setDraft('');
40
+ setStatus({ kind: 'idle' });
41
+ } catch (error) {
42
+ setStatus({
43
+ kind: 'error',
44
+ message: error instanceof Error ? error.message : 'failed to post',
45
+ });
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="console-composer">
51
+ <button
52
+ type="button"
53
+ className="console-composer-toggle"
54
+ aria-expanded={open}
55
+ onClick={() => setOpen((value) => !value)}
56
+ >
57
+ {open ? '✕ Cancel' : '💬 Add a comment'}
58
+ </button>
59
+ {posted.length > 0 && (
60
+ <div className="console-composer-posted">
61
+ {posted.map((comment) => (
62
+ <article
63
+ key={`${comment.author}:${comment.createdAt}:${comment.body}`}
64
+ className="console-comment"
65
+ >
66
+ <header
67
+ className="console-comment-header"
68
+ title={formatFullTimestamp(comment.createdAt)}
69
+ >
70
+ <span className="console-comment-author">
71
+ {comment.author === '' ? 'you' : comment.author}
72
+ </span>
73
+ <span className="console-comment-time">
74
+ {formatRelativeTime(comment.createdAt, now)}
75
+ </span>
76
+ </header>
77
+ <ConsoleMarkdownContent body={comment.body} />
78
+ </article>
79
+ ))}
80
+ </div>
81
+ )}
82
+ {open && (
83
+ <div className="console-composer-form">
84
+ <textarea
85
+ className="console-composer-input"
86
+ rows={3}
87
+ placeholder="Leave a comment…"
88
+ value={draft}
89
+ onChange={(event) => setDraft(event.target.value)}
90
+ />
91
+ <div className="console-composer-row">
92
+ <button
93
+ type="button"
94
+ className="console-composer-submit"
95
+ disabled={status.kind === 'posting'}
96
+ onClick={() => {
97
+ void submit();
98
+ }}
99
+ >
100
+ Comment
101
+ </button>
102
+ {status.kind === 'posting' && (
103
+ <span className="console-composer-status">Posting…</span>
104
+ )}
105
+ {status.kind === 'error' && (
106
+ <span
107
+ role="alert"
108
+ className="console-composer-status console-composer-error"
109
+ >
110
+ Failed: {status.message}
111
+ </span>
112
+ )}
113
+ </div>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ };
@@ -14,6 +14,7 @@ const meta: Meta<typeof ConsoleItemDetail> = {
14
14
  component: ConsoleItemDetail,
15
15
  args: {
16
16
  now: Date.parse('2026-06-19T12:00:00.000Z'),
17
+ commentComposer: null,
17
18
  operationBar: null,
18
19
  },
19
20
  };
@@ -34,18 +34,35 @@ const baseProps = {
34
34
  commitsError: null,
35
35
  relatedPullRequests: [],
36
36
  now,
37
+ commentComposer: <div>comment-composer</div>,
37
38
  operationBar: <div>operation-bar</div>,
38
39
  };
39
40
 
40
41
  describe('ConsoleItemDetail', () => {
41
- it('renders the PR title with the PR number, sub bar and changed files panel', () => {
42
+ it('renders the PR title with the PR number, sub bar and counted panels', () => {
42
43
  const { getByText, getAllByText } = render(
43
44
  <ConsoleItemDetail item={prItem} {...baseProps} />,
44
45
  );
45
46
  expect(getAllByText(`PR #${prItem.number}`).length).toBeGreaterThan(0);
46
- expect(getByText('Changed files')).toBeInTheDocument();
47
- expect(getByText('Commits')).toBeInTheDocument();
47
+ expect(
48
+ getByText(`Changed files (${consoleChangedFilesFixture.length})`),
49
+ ).toBeInTheDocument();
50
+ expect(
51
+ getByText(`Comments (${consoleCommentsFixture.length})`),
52
+ ).toBeInTheDocument();
53
+ expect(
54
+ getByText(`Commits (${consoleCommitsFixture.length})`),
55
+ ).toBeInTheDocument();
48
56
  expect(getByText('operation-bar')).toBeInTheDocument();
57
+ expect(getByText('comment-composer')).toBeInTheDocument();
58
+ });
59
+
60
+ it('renders the Description open link to the item url', () => {
61
+ const { getByText } = render(
62
+ <ConsoleItemDetail item={prItem} {...baseProps} />,
63
+ );
64
+ const openLink = getByText('open');
65
+ expect(openLink).toHaveAttribute('href', prItem.url);
49
66
  });
50
67
 
51
68
  it('renders the story tag and opened relative time', () => {
@@ -52,6 +52,7 @@ export type ConsoleItemDetailProps = {
52
52
  commitsError: string | null;
53
53
  relatedPullRequests: ConsoleRelatedPullRequestView[];
54
54
  now: number;
55
+ commentComposer: ReactNode;
55
56
  operationBar: ReactNode;
56
57
  };
57
58
 
@@ -75,6 +76,7 @@ export const ConsoleItemDetail = ({
75
76
  commitsError,
76
77
  relatedPullRequests,
77
78
  now,
79
+ commentComposer,
78
80
  operationBar,
79
81
  }: ConsoleItemDetailProps) => {
80
82
  const resolvedState = state?.state ?? 'open';
@@ -85,6 +87,12 @@ export const ConsoleItemDetail = ({
85
87
  const statusPalette = overlayStatus
86
88
  ? colorFromEnum(overlayStatus.color)
87
89
  : null;
90
+ const filesCount =
91
+ filesAreLoading || filesError !== null ? null : files.length;
92
+ const commentsCount =
93
+ commentsAreLoading || commentsError !== null ? null : comments.length;
94
+ const commitsCount =
95
+ commitsAreLoading || commitsError !== null ? null : commits.length;
88
96
 
89
97
  return (
90
98
  <article className="console-detail">
@@ -164,7 +172,19 @@ export const ConsoleItemDetail = ({
164
172
  opened {formatRelativeTime(item.createdAt, now)}
165
173
  </div>
166
174
 
167
- <ConsolePanel title="Description">
175
+ <ConsolePanel
176
+ title="Description"
177
+ headerAction={
178
+ <a
179
+ href={item.url}
180
+ className="console-panel-open-link"
181
+ target="_blank"
182
+ rel="noopener noreferrer"
183
+ >
184
+ open
185
+ </a>
186
+ }
187
+ >
168
188
  {bodyError !== null ? (
169
189
  <p role="alert" className="console-detail-body-error">
170
190
  Failed to load description: {bodyError}
@@ -177,7 +197,7 @@ export const ConsoleItemDetail = ({
177
197
  </ConsolePanel>
178
198
 
179
199
  {item.isPr && (
180
- <ConsolePanel title="Changed files">
200
+ <ConsolePanel title="Changed files" count={filesCount}>
181
201
  <ConsoleChangedFileList
182
202
  files={files}
183
203
  isLoading={filesAreLoading}
@@ -186,7 +206,11 @@ export const ConsoleItemDetail = ({
186
206
  </ConsolePanel>
187
207
  )}
188
208
 
189
- <ConsolePanel title="Comments" defaultCollapsed={item.isPr}>
209
+ <ConsolePanel
210
+ title="Comments"
211
+ count={commentsCount}
212
+ defaultCollapsed={item.isPr}
213
+ >
190
214
  <ConsoleCommentList
191
215
  comments={comments}
192
216
  isLoading={commentsAreLoading}
@@ -196,7 +220,7 @@ export const ConsoleItemDetail = ({
196
220
  </ConsolePanel>
197
221
 
198
222
  {item.isPr && (
199
- <ConsolePanel title="Commits" defaultCollapsed>
223
+ <ConsolePanel title="Commits" count={commitsCount} defaultCollapsed>
200
224
  <ConsoleCommitList
201
225
  commits={commits}
202
226
  isLoading={commitsAreLoading}
@@ -223,7 +247,9 @@ export const ConsoleItemDetail = ({
223
247
  />
224
248
  ))}
225
249
 
226
- <div className="console-detail-operations">{operationBar}</div>
250
+ {commentComposer}
251
+
252
+ <div className="console-actionbar">{operationBar}</div>
227
253
  </article>
228
254
  );
229
255
  };
@@ -24,3 +24,28 @@ export const Collapsed: Story = {
24
24
  children: <p style={{ padding: 12 }}>Panel body content</p>,
25
25
  },
26
26
  };
27
+
28
+ export const WithCountAndOpenLink: Story = {
29
+ args: {
30
+ title: 'Description',
31
+ headerAction: (
32
+ <a
33
+ href="https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/pull/851"
34
+ className="console-panel-open-link"
35
+ target="_blank"
36
+ rel="noopener noreferrer"
37
+ >
38
+ open
39
+ </a>
40
+ ),
41
+ children: <p style={{ padding: 12 }}>Panel body content</p>,
42
+ },
43
+ };
44
+
45
+ export const ChangedFilesWithCount: Story = {
46
+ args: {
47
+ title: 'Changed files',
48
+ count: 3,
49
+ children: <p style={{ padding: 12 }}>Panel body content</p>,
50
+ },
51
+ };
@@ -20,6 +20,33 @@ describe('ConsolePanel', () => {
20
20
  expect(queryByText('content')).toBeNull();
21
21
  });
22
22
 
23
+ it('appends the count to the title when provided', () => {
24
+ const { getByText } = render(
25
+ <ConsolePanel title="Changed files" count={3}>
26
+ <p>content</p>
27
+ </ConsolePanel>,
28
+ );
29
+ expect(getByText('Changed files (3)')).toBeInTheDocument();
30
+ });
31
+
32
+ it('omits the count when it is null', () => {
33
+ const { getByText } = render(
34
+ <ConsolePanel title="Description" count={null}>
35
+ <p>content</p>
36
+ </ConsolePanel>,
37
+ );
38
+ expect(getByText('Description')).toBeInTheDocument();
39
+ });
40
+
41
+ it('renders the header action node', () => {
42
+ const { getByText } = render(
43
+ <ConsolePanel title="Description" headerAction={<a href="/x">open</a>}>
44
+ <p>content</p>
45
+ </ConsolePanel>,
46
+ );
47
+ expect(getByText('open')).toBeInTheDocument();
48
+ });
49
+
23
50
  it('toggles collapsed state on the header button', () => {
24
51
  const { getByRole, queryByText } = render(
25
52
  <ConsolePanel title="Comments" defaultCollapsed>
@@ -2,6 +2,7 @@ import { type ReactNode, useState } from 'react';
2
2
 
3
3
  export type ConsolePanelProps = {
4
4
  title: string;
5
+ count?: number | null;
5
6
  defaultCollapsed?: boolean;
6
7
  headerAction?: ReactNode;
7
8
  children: ReactNode;
@@ -9,11 +10,13 @@ export type ConsolePanelProps = {
9
10
 
10
11
  export const ConsolePanel = ({
11
12
  title,
13
+ count = null,
12
14
  defaultCollapsed = false,
13
15
  headerAction,
14
16
  children,
15
17
  }: ConsolePanelProps) => {
16
18
  const [collapsed, setCollapsed] = useState<boolean>(defaultCollapsed);
19
+ const heading = count === null ? title : `${title} (${count})`;
17
20
  return (
18
21
  <section className="console-panel">
19
22
  <header className="console-panel-header">
@@ -24,7 +27,7 @@ export const ConsolePanel = ({
24
27
  onClick={() => setCollapsed((value) => !value)}
25
28
  >
26
29
  <span className="console-panel-caret">{collapsed ? '▸' : '▾'}</span>
27
- <span className="console-panel-title">{title}</span>
30
+ <span className="console-panel-title">{heading}</span>
28
31
  </button>
29
32
  {headerAction !== undefined && (
30
33
  <div className="console-panel-action">{headerAction}</div>