github-issue-tower-defence-management 1.112.4 → 1.112.6

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 (25) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/index-DKFbU8lr.css +1 -0
  3. package/bin/adapter/entry-points/console/ui-dist/assets/index-DUygHx5u.js +101 -0
  4. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +54 -0
  7. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +110 -8
  8. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +5 -0
  9. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +3 -0
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +7 -0
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.stories.tsx +17 -0
  12. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +4 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +49 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +23 -2
  15. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +68 -3
  16. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +16 -9
  17. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +33 -0
  18. package/src/adapter/entry-points/console/ui/src/index.css +3 -1
  19. package/src/adapter/entry-points/console/ui-dist/assets/index-DKFbU8lr.css +1 -0
  20. package/src/adapter/entry-points/console/ui-dist/assets/index-DUygHx5u.js +101 -0
  21. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  22. package/bin/adapter/entry-points/console/ui-dist/assets/index-CjYyb4uX.js +0 -101
  23. package/bin/adapter/entry-points/console/ui-dist/assets/index-D08V4Rj3.css +0 -1
  24. package/src/adapter/entry-points/console/ui-dist/assets/index-CjYyb4uX.js +0 -101
  25. package/src/adapter/entry-points/console/ui-dist/assets/index-D08V4Rj3.css +0 -1
@@ -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-CjYyb4uX.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-D08V4Rj3.css">
7
+ <script type="module" crossorigin src="/assets/index-DUygHx5u.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DKFbU8lr.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.112.4",
3
+ "version": "1.112.6",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -120,3 +120,57 @@ test('renders the Workflow Blocker tab leftmost and shows its detail operations'
120
120
  page.locator('.console-op-button', { hasText: '+1 day' }),
121
121
  ).toBeVisible();
122
122
  });
123
+
124
+ test('adds an inline review comment on a related pull request diff without hover on a touch viewport', async ({
125
+ browser,
126
+ }) => {
127
+ const touchContext = await browser.newContext({
128
+ viewport: { width: 390, height: 844 },
129
+ hasTouch: true,
130
+ isMobile: true,
131
+ });
132
+ const page = await touchContext.newPage();
133
+
134
+ await page.goto(harness.appRootUrl);
135
+
136
+ await tabByLabel(page, 'Failed Preparation').click();
137
+ await itemRowByText(
138
+ page,
139
+ 'Add inline review comments on the related pull request diff',
140
+ ).click();
141
+
142
+ const changedFile = page
143
+ .locator('.console-file-row', {
144
+ hasText: 'src/adapter/entry-points/console/ui/src/index.css',
145
+ })
146
+ .first();
147
+ await expect(changedFile).toBeVisible();
148
+ await changedFile.click();
149
+
150
+ const commentButton = page.locator('.console-diff-comment-button').first();
151
+ await expect(commentButton).toBeVisible();
152
+ const opacity = await commentButton.evaluate(
153
+ (element) => window.getComputedStyle(element).opacity,
154
+ );
155
+ expect(Number(opacity)).toBeGreaterThan(0);
156
+
157
+ await commentButton.click();
158
+ await page
159
+ .locator('.console-diff-composer-input')
160
+ .fill('Please verify this opacity change on touch devices.');
161
+ await page.locator('.console-diff-composer-submit').click();
162
+
163
+ await expect(page.locator('.console-diff-composer-posted')).toHaveText(
164
+ 'Comment posted.',
165
+ );
166
+
167
+ expect(harness.reviewCommentCalls).toHaveLength(1);
168
+ expect(harness.reviewCommentCalls[0].url).toBe(
169
+ 'https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/pull/912',
170
+ );
171
+ expect(harness.reviewCommentCalls[0].body).toBe(
172
+ 'Please verify this opacity change on touch devices.',
173
+ );
174
+
175
+ await touchContext.close();
176
+ });
@@ -9,6 +9,7 @@ import type {
9
9
  IssueRepository,
10
10
  PullRequestCommit,
11
11
  PullRequestDetail,
12
+ PullRequestFile,
12
13
  RelatedPullRequest,
13
14
  } from '../../../../../domain/usecases/adapter-interfaces/IssueRepository';
14
15
  import type { ConsoleProjectBinding } from '../../consoleOperationApi';
@@ -18,6 +19,14 @@ import { startWebServer } from '../../webServer';
18
19
  export const CONSOLE_E2E_PJCODE = 'umino';
19
20
  export const CONSOLE_E2E_TOKEN = 'console-e2e-fixture-token-3f9c1a';
20
21
 
22
+ export type ConsoleE2eReviewCommentCall = {
23
+ url: string;
24
+ path: string;
25
+ line: number;
26
+ side: string;
27
+ body: string;
28
+ };
29
+
21
30
  type ConsoleFixtureListItem = {
22
31
  number: number;
23
32
  title: string;
@@ -54,6 +63,25 @@ type ConsoleFixtureSnapshot = {
54
63
  const REPO_NAME_WITH_OWNER =
55
64
  'HiromiShikata/npm-cli-github-issue-tower-defence-management';
56
65
 
66
+ export const CONSOLE_E2E_INLINE_COMMENT_ISSUE_URL = `https://github.com/${REPO_NAME_WITH_OWNER}/issues/911`;
67
+ export const CONSOLE_E2E_INLINE_COMMENT_PR_URL = `https://github.com/${REPO_NAME_WITH_OWNER}/pull/912`;
68
+
69
+ const INLINE_COMMENT_PR_FILE: PullRequestFile = {
70
+ filename: 'src/adapter/entry-points/console/ui/src/index.css',
71
+ status: 'modified',
72
+ additions: 3,
73
+ deletions: 1,
74
+ patch: `@@ -959,7 +959,9 @@
75
+ background: transparent;
76
+ color: #6e7681;
77
+ font-size: 0.875rem;
78
+ cursor: pointer;
79
+ - opacity: 0;
80
+ + opacity: 0.6;
81
+ + border-color: #30363d;
82
+ }`,
83
+ };
84
+
57
85
  const AWAITING_WORKSPACE_OPTION: ConsoleFixtureFieldOption = {
58
86
  id: 'd1c19cce',
59
87
  name: 'Awaiting Workspace',
@@ -198,7 +226,15 @@ export const CONSOLE_E2E_TAB_ITEMS: Record<string, ConsoleFixtureListItem[]> = {
198
226
  '2026-06-17T05:48:09.000Z',
199
227
  ),
200
228
  ],
201
- 'failed-preparation': [],
229
+ 'failed-preparation': [
230
+ issueItem(
231
+ 911,
232
+ 'Add inline review comments on the related pull request diff',
233
+ 'FPR00911',
234
+ 'TDPM Console port',
235
+ '2026-06-18T03:12:00.000Z',
236
+ ),
237
+ ],
202
238
  'todo-by-human': [
203
239
  issueItem(
204
240
  869,
@@ -286,7 +322,36 @@ const notImplemented = (method: string): never => {
286
322
  throw new Error(`console E2E stub does not implement ${method}`);
287
323
  };
288
324
 
289
- const createStubIssueRepository = (): IssueRepository => ({
325
+ const inlineCommentRelatedPullRequest: RelatedPullRequest = {
326
+ url: CONSOLE_E2E_INLINE_COMMENT_PR_URL,
327
+ branchName: 'feature/911-related-pr-inline-comments',
328
+ createdAt: new Date('2026-06-18T03:30:00.000Z'),
329
+ isDraft: false,
330
+ isConflicted: false,
331
+ isPassedAllCiJob: true,
332
+ isCiStateSuccess: true,
333
+ isResolvedAllReviewComments: true,
334
+ isBranchOutOfDate: false,
335
+ missingRequiredCheckNames: [],
336
+ };
337
+
338
+ const inlineCommentPullRequestDetail: PullRequestDetail = {
339
+ title: 'Add inline review comments on the related pull request diff',
340
+ state: 'open',
341
+ merged: false,
342
+ isDraft: false,
343
+ additions: 3,
344
+ deletions: 1,
345
+ changedFiles: 1,
346
+ headRefName: 'feature/911-related-pr-inline-comments',
347
+ baseRefName: 'main',
348
+ author: 'HiromiShikata',
349
+ files: [INLINE_COMMENT_PR_FILE],
350
+ };
351
+
352
+ const createStubIssueRepository = (
353
+ reviewCommentCalls: ConsoleE2eReviewCommentCall[],
354
+ ): IssueRepository => ({
290
355
  getAllIssues: () => notImplemented('getAllIssues'),
291
356
  getIssueByUrl: async (url: string): Promise<Issue | null> =>
292
357
  buildIssueForUrl(url),
@@ -306,12 +371,29 @@ const createStubIssueRepository = (): IssueRepository => ({
306
371
  get: async (issueUrl: string): Promise<Issue | null> =>
307
372
  buildIssueForUrl(issueUrl),
308
373
  update: () => notImplemented('update'),
309
- findRelatedOpenPRs: async (): Promise<RelatedPullRequest[]> => [],
374
+ findRelatedOpenPRs: async (url: string): Promise<RelatedPullRequest[]> =>
375
+ url === CONSOLE_E2E_INLINE_COMMENT_ISSUE_URL
376
+ ? [inlineCommentRelatedPullRequest]
377
+ : [],
310
378
  getOpenPullRequest: async (): Promise<RelatedPullRequest | null> => null,
311
379
  getPullRequestChangedFilePaths: async (): Promise<string[]> => [],
312
380
  approvePullRequest: async (): Promise<void> => undefined,
313
381
  requestChangesWithInlineComment: async (): Promise<void> => undefined,
314
- createPullRequestReviewComment: async (): Promise<void> => undefined,
382
+ createPullRequestReviewComment: async (
383
+ prUrl: string,
384
+ filePath: string,
385
+ line: number,
386
+ side: string,
387
+ commentBody: string,
388
+ ): Promise<void> => {
389
+ reviewCommentCalls.push({
390
+ url: prUrl,
391
+ path: filePath,
392
+ line,
393
+ side,
394
+ body: commentBody,
395
+ });
396
+ },
315
397
  closePullRequest: async (): Promise<void> => undefined,
316
398
  closeIssueByUrl: async (): Promise<void> => undefined,
317
399
  deletePullRequestBranch: () => notImplemented('deletePullRequestBranch'),
@@ -323,7 +405,12 @@ const createStubIssueRepository = (): IssueRepository => ({
323
405
  getIssueOrPullRequestBody: async (): Promise<string> =>
324
406
  '## Console E2E fixture\n\nThis body is served by the isolated E2E stub.',
325
407
  getIssueOrPullRequestComments: async (): Promise<IssueComment[]> => [],
326
- getPullRequestDetail: async (): Promise<PullRequestDetail | null> => null,
408
+ getPullRequestDetail: async (
409
+ url: string,
410
+ ): Promise<PullRequestDetail | null> =>
411
+ url === CONSOLE_E2E_INLINE_COMMENT_PR_URL
412
+ ? inlineCommentPullRequestDetail
413
+ : null,
327
414
  getPullRequestCommits: async (): Promise<PullRequestCommit[]> => [],
328
415
  getIssueOrPullRequestState: async (
329
416
  url: string,
@@ -332,13 +419,24 @@ const createStubIssueRepository = (): IssueRepository => ({
332
419
  merged: false,
333
420
  isPullRequest: url.includes('/pull/'),
334
421
  }),
335
- getPullRequestSummary: async (): Promise<{
422
+ getPullRequestSummary: async (
423
+ url: string,
424
+ ): Promise<{
336
425
  title: string;
337
426
  body: string;
338
427
  additions: number;
339
428
  deletions: number;
340
429
  changedFiles: number;
341
- } | null> => null,
430
+ } | null> =>
431
+ url === CONSOLE_E2E_INLINE_COMMENT_PR_URL
432
+ ? {
433
+ title: 'Add inline review comments on the related pull request diff',
434
+ body: 'Wires the add-comment handler on the related pull request diff path.',
435
+ additions: 3,
436
+ deletions: 1,
437
+ changedFiles: 1,
438
+ }
439
+ : null,
342
440
  });
343
441
 
344
442
  export type ConsoleE2eHarness = {
@@ -346,6 +444,7 @@ export type ConsoleE2eHarness = {
346
444
  appUrl: string;
347
445
  appRootUrl: string;
348
446
  consoleDataOutputDir: string;
447
+ reviewCommentCalls: ConsoleE2eReviewCommentCall[];
349
448
  stop: () => Promise<void>;
350
449
  };
351
450
 
@@ -362,11 +461,13 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
362
461
  ): Promise<ConsoleProjectBinding | null> =>
363
462
  pjcode === CONSOLE_E2E_PJCODE ? { pjcode, project } : null;
364
463
 
464
+ const reviewCommentCalls: ConsoleE2eReviewCommentCall[] = [];
465
+
365
466
  const server = await startWebServer({
366
467
  accessToken: CONSOLE_E2E_TOKEN,
367
468
  uiDistDir,
368
469
  consoleDataOutputDir,
369
- issueRepository: createStubIssueRepository(),
470
+ issueRepository: createStubIssueRepository(reviewCommentCalls),
370
471
  resolveProject,
371
472
  issueTitleStateCache: new IssueTitleStateCache(),
372
473
  inTmuxDataDir: null,
@@ -391,6 +492,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
391
492
  appUrl,
392
493
  appRootUrl,
393
494
  consoleDataOutputDir,
495
+ reviewCommentCalls,
394
496
  stop: async (): Promise<void> => {
395
497
  await closeServer(server);
396
498
  fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { buildImageProxyUrl } from '../../lib/imageProxy';
3
3
  import {
4
+ consoleCodeFenceBodyFixture,
4
5
  consoleMarkdownBodyFixture,
5
6
  consoleMarkdownImageBodyFixture,
6
7
  consoleMermaidBodyFixture,
@@ -24,6 +25,10 @@ export const WithMermaidFence: Story = {
24
25
  args: { body: consoleMermaidBodyFixture },
25
26
  };
26
27
 
28
+ export const WithCodeFence: Story = {
29
+ args: { body: consoleCodeFenceBodyFixture },
30
+ };
31
+
27
32
  export const WithProxiedGitHubImages: Story = {
28
33
  args: {
29
34
  body: consoleMarkdownImageBodyFixture,
@@ -148,5 +148,8 @@ export const IssueWithLinkedPullRequest: Story = {
148
148
  commitsError: null,
149
149
  },
150
150
  ],
151
+ buildAddInlineComment: (prUrl) => async (path, line, side, body) => {
152
+ window.alert(`comment on ${prUrl} ${path}:${line} (${side})\n${body}`);
153
+ },
151
154
  },
152
155
  };
@@ -59,6 +59,7 @@ export type ConsoleItemDetailProps = {
59
59
  operationBar: ReactNode;
60
60
  buildImageProxyUrl?: ImageProxyUrlBuilder;
61
61
  onAddInlineComment?: ConsoleAddInlineComment;
62
+ buildAddInlineComment?: (prUrl: string) => ConsoleAddInlineComment;
62
63
  };
63
64
 
64
65
  export const ConsoleItemDetail = ({
@@ -85,6 +86,7 @@ export const ConsoleItemDetail = ({
85
86
  operationBar,
86
87
  buildImageProxyUrl,
87
88
  onAddInlineComment,
89
+ buildAddInlineComment,
88
90
  }: ConsoleItemDetailProps) => {
89
91
  const resolvedState = state?.state ?? 'open';
90
92
  const merged = state?.merged ?? false;
@@ -257,6 +259,11 @@ export const ConsoleItemDetail = ({
257
259
  commitsError={related.commitsError}
258
260
  now={now}
259
261
  buildImageProxyUrl={buildImageProxyUrl}
262
+ onAddInlineComment={
263
+ buildAddInlineComment
264
+ ? buildAddInlineComment(related.pullRequest.url)
265
+ : undefined
266
+ }
260
267
  />
261
268
  ))}
262
269
 
@@ -29,3 +29,20 @@ export const WithDetail: Story = {
29
29
  commitsError: null,
30
30
  },
31
31
  };
32
+
33
+ export const WithInlineComments: Story = {
34
+ args: {
35
+ pullRequest: consoleRelatedPullRequestsFixture[0],
36
+ body: consoleRelatedPullRequestsFixture[0].summary?.body ?? '',
37
+ bodyIsLoading: false,
38
+ files: consoleChangedFilesFixture,
39
+ filesAreLoading: false,
40
+ filesError: null,
41
+ commits: consoleCommitsFixture,
42
+ commitsAreLoading: false,
43
+ commitsError: null,
44
+ onAddInlineComment: async (path, line, side, body) => {
45
+ window.alert(`comment on ${path}:${line} (${side})\n${body}`);
46
+ },
47
+ },
48
+ };
@@ -9,6 +9,7 @@ import { ConsolePanel } from '../layout/ConsolePanel';
9
9
  import { ConsoleChangedFileList } from './ConsoleChangedFileList';
10
10
  import { ConsoleCommitList } from './ConsoleCommitList';
11
11
  import { ConsoleCopyUrlButton } from './ConsoleCopyUrlButton';
12
+ import type { ConsoleAddInlineComment } from './ConsoleFileDiff';
12
13
 
13
14
  export type ConsolePullRequestSectionProps = {
14
15
  pullRequest: ConsoleRelatedPullRequest;
@@ -22,6 +23,7 @@ export type ConsolePullRequestSectionProps = {
22
23
  commitsError: string | null;
23
24
  now: number;
24
25
  buildImageProxyUrl?: ImageProxyUrlBuilder;
26
+ onAddInlineComment?: ConsoleAddInlineComment;
25
27
  };
26
28
 
27
29
  export const ConsolePullRequestDetail = ({
@@ -36,6 +38,7 @@ export const ConsolePullRequestDetail = ({
36
38
  commitsError,
37
39
  now,
38
40
  buildImageProxyUrl,
41
+ onAddInlineComment,
39
42
  }: ConsolePullRequestSectionProps) => {
40
43
  const summary = pullRequest.summary;
41
44
  const filesCount =
@@ -87,6 +90,7 @@ export const ConsolePullRequestDetail = ({
87
90
  files={files}
88
91
  isLoading={filesAreLoading}
89
92
  error={filesError}
93
+ onAddInlineComment={onAddInlineComment}
90
94
  />
91
95
  </ConsolePanel>
92
96
  <ConsolePanel title="Commits" count={commitsCount} defaultCollapsed>
@@ -82,6 +82,55 @@ describe('splitMarkdownSegments', () => {
82
82
  { kind: 'markdown', source: '```mermaid\ngraph TD; A-->B;' },
83
83
  ]);
84
84
  });
85
+
86
+ it('keeps a regular fenced code block as a single markdown segment', () => {
87
+ const segments = splitMarkdownSegments('```ts\nconst answer = 42;\n```');
88
+ expect(segments.map(stripKey)).toEqual([
89
+ { kind: 'markdown', source: '```ts\nconst answer = 42;\n```' },
90
+ ]);
91
+ const html = renderMarkdownToSafeHtml(
92
+ (segments[0] as { source: string }).source,
93
+ );
94
+ expect(html).toContain('<pre>');
95
+ expect(html).toContain('<code');
96
+ expect(html).toContain('const answer = 42;');
97
+ expect(html).not.toContain('```');
98
+ });
99
+
100
+ it('does not treat a mermaid fence inside a regular code block as a diagram', () => {
101
+ const segments = splitMarkdownSegments(
102
+ '```text\n```mermaid\ngraph TD; A-->B;\n```\n```',
103
+ );
104
+ expect(segments.map(stripKey)).toEqual([
105
+ {
106
+ kind: 'markdown',
107
+ source: '```text\n```mermaid\ngraph TD; A-->B;\n```\n```',
108
+ },
109
+ ]);
110
+ });
111
+
112
+ it('separates a mermaid fence from a following regular code fence', () => {
113
+ const segments = splitMarkdownSegments(
114
+ 'intro\n\n```mermaid\ngraph TD; A-->B;\n```\n\n```ts\nconst answer = 42;\n```',
115
+ );
116
+ expect(segments.map(stripKey)).toEqual([
117
+ { kind: 'markdown', source: 'intro\n' },
118
+ { kind: 'mermaid', code: 'graph TD; A-->B;' },
119
+ { kind: 'markdown', source: '\n```ts\nconst answer = 42;\n```' },
120
+ ]);
121
+ const codeSegment = segments[2] as { source: string };
122
+ const html = renderMarkdownToSafeHtml(codeSegment.source);
123
+ expect(html).toContain('<pre>');
124
+ expect(html).toContain('<code');
125
+ expect(html).not.toContain('```');
126
+ });
127
+
128
+ it('keeps an unterminated regular fence as markdown', () => {
129
+ const segments = splitMarkdownSegments('```ts\nconst answer = 42;');
130
+ expect(segments.map(stripKey)).toEqual([
131
+ { kind: 'markdown', source: '```ts\nconst answer = 42;' },
132
+ ]);
133
+ });
85
134
  });
86
135
 
87
136
  describe('hasMermaidFence', () => {
@@ -27,6 +27,14 @@ export type ConsoleMarkdownSegment =
27
27
 
28
28
  const MERMAID_FENCE = /^```mermaid[^\n]*\n([\s\S]*?)\n```$/;
29
29
 
30
+ const isMermaidFenceOpen = (line: string): boolean =>
31
+ /^```mermaid\s*$/.test(line.trim());
32
+
33
+ const isCodeFenceOpen = (line: string): boolean =>
34
+ /^```/.test(line.trim()) && !isMermaidFenceOpen(line);
35
+
36
+ const isFenceClose = (line: string): boolean => line.trim() === '```';
37
+
30
38
  export const splitMarkdownSegments = (
31
39
  source: string,
32
40
  ): ConsoleMarkdownSegment[] => {
@@ -34,6 +42,7 @@ export const splitMarkdownSegments = (
34
42
  const segments: ConsoleMarkdownSegment[] = [];
35
43
  let markdownBuffer: string[] = [];
36
44
  let mermaidBuffer: string[] | null = null;
45
+ let insideCodeFence = false;
37
46
  let sequence = 0;
38
47
 
39
48
  const flushMarkdown = (): void => {
@@ -49,13 +58,20 @@ export const splitMarkdownSegments = (
49
58
  };
50
59
 
51
60
  for (const line of lines) {
52
- if (mermaidBuffer === null && /^```mermaid\s*$/.test(line.trim())) {
61
+ if (insideCodeFence) {
62
+ markdownBuffer.push(line);
63
+ if (isFenceClose(line)) {
64
+ insideCodeFence = false;
65
+ }
66
+ continue;
67
+ }
68
+ if (mermaidBuffer === null && isMermaidFenceOpen(line)) {
53
69
  flushMarkdown();
54
70
  mermaidBuffer = [];
55
71
  continue;
56
72
  }
57
73
  if (mermaidBuffer !== null) {
58
- if (line.trim() === '```') {
74
+ if (isFenceClose(line)) {
59
75
  segments.push({
60
76
  kind: 'mermaid',
61
77
  key: `mermaid:${sequence}`,
@@ -68,6 +84,11 @@ export const splitMarkdownSegments = (
68
84
  mermaidBuffer.push(line);
69
85
  continue;
70
86
  }
87
+ if (isCodeFenceOpen(line)) {
88
+ markdownBuffer.push(line);
89
+ insideCodeFence = true;
90
+ continue;
91
+ }
71
92
  markdownBuffer.push(line);
72
93
  }
73
94
 
@@ -2,8 +2,14 @@ import { fireEvent, render, waitFor } from '@testing-library/react';
2
2
  import type { ConsoleCaches } from '../hooks/useConsoleCaches';
3
3
  import type { ConsoleOperationsApi } from '../hooks/useConsoleOperations';
4
4
  import { ResourceCache } from '../lib/resourceCache';
5
+ import type {
6
+ ConsoleChangedFile,
7
+ ConsoleRelatedPullRequest,
8
+ } from '../logic/types';
5
9
  import {
10
+ consoleChangedFilesFixture,
6
11
  consoleListItemsFixture,
12
+ consoleRelatedPullRequestsFixture,
7
13
  consoleStatusOptionsFixture,
8
14
  consoleStoryColorsFixture,
9
15
  consoleStoryOptionsFixture,
@@ -15,14 +21,20 @@ jest.mock('../lib/mermaidLoader', () => ({
15
21
  }));
16
22
 
17
23
  const prItem = consoleListItemsFixture[0];
24
+ const issueItem = consoleListItemsFixture[2];
18
25
 
19
- const buildCaches = (): ConsoleCaches => {
26
+ type CachesOverrides = {
27
+ relatedPrs?: ConsoleRelatedPullRequest[];
28
+ prFiles?: ConsoleChangedFile[];
29
+ };
30
+
31
+ const buildCaches = (overrides: CachesOverrides = {}): ConsoleCaches => {
20
32
  const client = {
21
33
  fetchItemBody: async () => '# body',
22
34
  fetchComments: async () => [],
23
- fetchPrFiles: async () => [],
35
+ fetchPrFiles: async () => overrides.prFiles ?? [],
24
36
  fetchPrCommits: async () => [],
25
- fetchRelatedPrs: async () => [],
37
+ fetchRelatedPrs: async () => overrides.relatedPrs ?? [],
26
38
  fetchIssueState: async () => ({
27
39
  state: 'open',
28
40
  merged: false,
@@ -90,4 +102,57 @@ describe('ConsoleItemDetailContainer', () => {
90
102
  'approve',
91
103
  );
92
104
  });
105
+
106
+ it('routes an inline review comment on an issue related pull request to that pull request url', async () => {
107
+ const operations = buildOperations();
108
+ const relatedPullRequest = consoleRelatedPullRequestsFixture[0];
109
+ const { container, findByRole, getAllByRole, getByPlaceholderText } =
110
+ render(
111
+ <ConsoleItemDetailContainer
112
+ tab="unread"
113
+ item={issueItem}
114
+ caches={buildCaches({
115
+ relatedPrs: [relatedPullRequest],
116
+ prFiles: consoleChangedFilesFixture,
117
+ })}
118
+ operations={operations}
119
+ statusOptions={consoleStatusOptionsFixture}
120
+ storyOptions={consoleStoryOptionsFixture}
121
+ storyColors={consoleStoryColorsFixture}
122
+ storyName="TDPM Console port"
123
+ overlayStatus={null}
124
+ now={Date.parse('2026-06-19T12:00:00.000Z')}
125
+ onQueueAction={jest.fn()}
126
+ />,
127
+ );
128
+
129
+ const fileRow = await findByRole('button', {
130
+ name: new RegExp(consoleChangedFilesFixture[0].path),
131
+ });
132
+ fireEvent.click(fileRow);
133
+
134
+ const commentButton = getAllByRole('button', {
135
+ name: /^Comment on line/,
136
+ })[0];
137
+ fireEvent.click(commentButton);
138
+
139
+ fireEvent.change(
140
+ getByPlaceholderText('Leave a review comment on this line…'),
141
+ { target: { value: 'Please rename this variable.' } },
142
+ );
143
+ const submitButton = container.querySelector(
144
+ '.console-diff-composer-submit',
145
+ );
146
+ expect(submitButton).not.toBeNull();
147
+ fireEvent.click(submitButton as Element);
148
+
149
+ const addInlineReviewComment =
150
+ operations.addInlineReviewComment as jest.Mock;
151
+ await waitFor(() => {
152
+ expect(addInlineReviewComment).toHaveBeenCalledTimes(1);
153
+ });
154
+ const call = addInlineReviewComment.mock.calls[0];
155
+ expect(call[0]).toBe(relatedPullRequest.url);
156
+ expect(call[4]).toBe('Please rename this variable.');
157
+ });
93
158
  });
@@ -1,4 +1,4 @@
1
- import { useCallback } from 'react';
1
+ import { useCallback, useMemo } from 'react';
2
2
  import { ConsoleCommentComposer } from '../components/detail/ConsoleCommentComposer';
3
3
  import { ConsoleItemDetail } from '../components/detail/ConsoleItemDetail';
4
4
  import { ConsoleOperationMenu } from '../components/operations/ConsoleOperationMenu';
@@ -60,14 +60,20 @@ export const ConsoleItemDetailContainer = ({
60
60
  [token],
61
61
  );
62
62
  const hasPullRequest = item.isPr || detail.relatedPullRequests.length > 0;
63
- const addInlineComment = useCallback(
64
- (
65
- path: string,
66
- line: number,
67
- side: ConsoleReviewCommentSide,
68
- body: string,
69
- ) => operations.addInlineReviewComment(item.url, path, line, side, body),
70
- [operations, item.url],
63
+ const buildAddInlineComment = useCallback(
64
+ (prUrl: string) =>
65
+ (
66
+ path: string,
67
+ line: number,
68
+ side: ConsoleReviewCommentSide,
69
+ body: string,
70
+ ) =>
71
+ operations.addInlineReviewComment(prUrl, path, line, side, body),
72
+ [operations],
73
+ );
74
+ const addInlineComment = useMemo(
75
+ () => buildAddInlineComment(item.url),
76
+ [buildAddInlineComment, item.url],
71
77
  );
72
78
 
73
79
  const handlers: ConsoleOperationHandlers = {
@@ -148,6 +154,7 @@ export const ConsoleItemDetailContainer = ({
148
154
  now={now}
149
155
  buildImageProxyUrl={resolveImageProxyUrl}
150
156
  onAddInlineComment={item.isPr ? addInlineComment : undefined}
157
+ buildAddInlineComment={buildAddInlineComment}
151
158
  commentComposer={
152
159
  <ConsoleCommentComposer
153
160
  isPr={item.isPr}
@@ -166,6 +166,39 @@ For reference, the avatar comes from an external host:
166
166
  ![external avatar](https://example.com/avatar.png)
167
167
  `;
168
168
 
169
+ export const consoleCodeFenceBodyFixture = `## Reproduction
170
+
171
+ A TypeScript fenced code block MUST render as a styled code box rather than
172
+ literal backticks:
173
+
174
+ \`\`\`ts
175
+ export const splitMarkdownSegments = (source: string): ConsoleMarkdownSegment[] => {
176
+ const lines = source.split('\\n');
177
+ return lines.length > 0 ? parse(lines) : [];
178
+ };
179
+ \`\`\`
180
+
181
+ A code block that documents a mermaid fence MUST stay literal text and MUST NOT
182
+ be turned into a diagram:
183
+
184
+ \`\`\`text
185
+ \`\`\`mermaid
186
+ graph TD; A-->B;
187
+ \`\`\`
188
+
189
+ The diagram below MUST still render as an actual diagram:
190
+
191
+ \`\`\`mermaid
192
+ sequenceDiagram
193
+ participant Browser
194
+ participant ConsoleServer
195
+ Browser->>ConsoleServer: GET /api/itembody?url=...
196
+ ConsoleServer-->>Browser: { body }
197
+ \`\`\`
198
+
199
+ Inline \`code\` spans are unaffected.
200
+ `;
201
+
169
202
  export const consoleMermaidBodyFixture = `Here is the request flow:
170
203
 
171
204
  \`\`\`mermaid