github-issue-tower-defence-management 1.92.1 → 1.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.test.tsx +49 -1
  4. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleChangedFileList.tsx +36 -20
  5. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.stories.tsx +19 -0
  6. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +24 -0
  7. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +30 -0
  8. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +30 -24
  9. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.stories.tsx +47 -0
  10. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +54 -0
  11. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +36 -0
  12. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +117 -0
  13. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +105 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.test.ts +115 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleSwipeNavigation.ts +115 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +153 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +103 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.test.ts +81 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/logic/diff.ts +61 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.test.ts +53 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/logic/navigation.ts +33 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.test.ts +34 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/logic/swipe.ts +28 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +9 -1
  25. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +51 -6
  26. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +263 -17
  27. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +85 -3
  28. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +19 -3
  29. package/src/adapter/entry-points/console/ui/src/index.css +205 -1
  30. package/src/adapter/entry-points/console/ui-dist/assets/index-C5nxEEOu.css +1 -0
  31. package/src/adapter/entry-points/console/ui-dist/assets/index-DoJ05EuW.js +101 -0
  32. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  33. package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +0 -1
  34. package/src/adapter/entry-points/console/ui-dist/assets/index-BSNMvjcB.js +0 -100
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.93.0](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.92.1...v1.93.0) (2026-06-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * **console:** cancellable action toast, auto-advance, file diff, and swipe navigation for reference parity ([#893](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/893)) ([9f983af](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/commit/9f983af0dfff353ff22c9d126b18aff31bbcb082)), closes [#891](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/issues/891)
7
+
1
8
  ## [1.92.1](https://github.com/HiromiShikata/npm-cli-github-issue-tower-defence-management/compare/v1.92.0...v1.92.1) (2026-06-21)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.92.1",
3
+ "version": "1.93.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import { render } from '@testing-library/react';
1
+ import { fireEvent, render } from '@testing-library/react';
2
2
  import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
3
  import { ConsoleChangedFileList } from './ConsoleChangedFileList';
4
4
 
@@ -19,6 +19,54 @@ describe('ConsoleChangedFileList', () => {
19
19
  expect(getByText('M')).toBeInTheDocument();
20
20
  });
21
21
 
22
+ it('keeps the diff hidden until the file row is clicked', () => {
23
+ const { container, getByText } = render(
24
+ <ConsoleChangedFileList
25
+ files={consoleChangedFilesFixture}
26
+ isLoading={false}
27
+ error={null}
28
+ />,
29
+ );
30
+ expect(container.querySelector('.console-file-diff')).toBeNull();
31
+ fireEvent.click(
32
+ getByText('src/adapter/entry-points/console/consoleServer.ts'),
33
+ );
34
+ expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
35
+ const codeText = [...container.querySelectorAll('.console-diff-code')].map(
36
+ (cell) => cell.textContent,
37
+ );
38
+ expect(codeText).toContain('+ npm ci');
39
+ });
40
+
41
+ it('collapses the diff again when the file row is clicked twice', () => {
42
+ const { container, getByText } = render(
43
+ <ConsoleChangedFileList
44
+ files={consoleChangedFilesFixture}
45
+ isLoading={false}
46
+ error={null}
47
+ />,
48
+ );
49
+ const path = getByText('src/adapter/entry-points/console/consoleServer.ts');
50
+ fireEvent.click(path);
51
+ expect(container.querySelector('.console-file-diff')).toBeInTheDocument();
52
+ fireEvent.click(path);
53
+ expect(container.querySelector('.console-file-diff')).toBeNull();
54
+ });
55
+
56
+ it('marks the expanded file row via aria-expanded', () => {
57
+ const { getAllByRole } = render(
58
+ <ConsoleChangedFileList
59
+ files={consoleChangedFilesFixture}
60
+ isLoading={false}
61
+ error={null}
62
+ />,
63
+ );
64
+ const firstRow = getAllByRole('button')[0];
65
+ expect(firstRow).toHaveAttribute('aria-expanded', 'false');
66
+ fireEvent.click(firstRow);
67
+ expect(firstRow).toHaveAttribute('aria-expanded', 'true');
68
+ });
69
+
22
70
  it('shows the loading state', () => {
23
71
  const { getByText } = render(
24
72
  <ConsoleChangedFileList files={[]} isLoading error={null} />,
@@ -1,5 +1,7 @@
1
+ import { useState } from 'react';
1
2
  import { fileStatusBadge } from '../../logic/fileStatus';
2
3
  import type { ConsoleChangedFile } from '../../logic/types';
4
+ import { ConsoleFileDiff } from './ConsoleFileDiff';
3
5
 
4
6
  export type ConsoleChangedFileListProps = {
5
7
  files: ConsoleChangedFile[];
@@ -7,6 +9,37 @@ export type ConsoleChangedFileListProps = {
7
9
  error: string | null;
8
10
  };
9
11
 
12
+ const ConsoleChangedFileRow = ({ file }: { file: ConsoleChangedFile }) => {
13
+ const [expanded, setExpanded] = useState<boolean>(false);
14
+ const badge = fileStatusBadge(file.status);
15
+ return (
16
+ <li className="console-file">
17
+ <button
18
+ type="button"
19
+ className="console-file-row"
20
+ aria-expanded={expanded}
21
+ onClick={() => setExpanded((value) => !value)}
22
+ >
23
+ <span className="console-file-caret">{expanded ? '▾' : '▸'}</span>
24
+ <span
25
+ className="console-file-badge"
26
+ style={{ color: badge.color, borderColor: badge.color }}
27
+ >
28
+ {badge.label}
29
+ </span>
30
+ <span className="console-file-path">{file.path}</span>
31
+ <span className="console-file-stat console-file-add">
32
+ +{file.additions}
33
+ </span>
34
+ <span className="console-file-stat console-file-del">
35
+ -{file.deletions}
36
+ </span>
37
+ </button>
38
+ {expanded && <ConsoleFileDiff patch={file.patch} />}
39
+ </li>
40
+ );
41
+ };
42
+
10
43
  export const ConsoleChangedFileList = ({
11
44
  files,
12
45
  isLoading,
@@ -30,26 +63,9 @@ export const ConsoleChangedFileList = ({
30
63
 
31
64
  return (
32
65
  <ul className="console-files">
33
- {files.map((file) => {
34
- const badge = fileStatusBadge(file.status);
35
- return (
36
- <li key={file.path} className="console-file">
37
- <span
38
- className="console-file-badge"
39
- style={{ color: badge.color, borderColor: badge.color }}
40
- >
41
- {badge.label}
42
- </span>
43
- <span className="console-file-path">{file.path}</span>
44
- <span className="console-file-stat console-file-add">
45
- +{file.additions}
46
- </span>
47
- <span className="console-file-stat console-file-del">
48
- -{file.deletions}
49
- </span>
50
- </li>
51
- );
52
- })}
66
+ {files.map((file) => (
67
+ <ConsoleChangedFileRow key={file.path} file={file} />
68
+ ))}
53
69
  </ul>
54
70
  );
55
71
  };
@@ -0,0 +1,19 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
+ import { ConsoleFileDiff } from './ConsoleFileDiff';
4
+
5
+ const meta: Meta<typeof ConsoleFileDiff> = {
6
+ title: 'Console/ConsoleFileDiff',
7
+ component: ConsoleFileDiff,
8
+ args: { patch: consoleChangedFilesFixture[0].patch },
9
+ };
10
+
11
+ export default meta;
12
+
13
+ type Story = StoryObj<typeof ConsoleFileDiff>;
14
+
15
+ export const WithPatch: Story = {};
16
+
17
+ export const NoPatch: Story = {
18
+ args: { patch: null },
19
+ };
@@ -0,0 +1,24 @@
1
+ import { render } from '@testing-library/react';
2
+ import { consoleChangedFilesFixture } from '../../testing/fixtures';
3
+ import { ConsoleFileDiff } from './ConsoleFileDiff';
4
+
5
+ describe('ConsoleFileDiff', () => {
6
+ it('renders added, removed and context lines from the patch', () => {
7
+ const { container } = render(
8
+ <ConsoleFileDiff patch={consoleChangedFilesFixture[0].patch} />,
9
+ );
10
+ const codeText = [...container.querySelectorAll('.console-diff-code')].map(
11
+ (cell) => cell.textContent,
12
+ );
13
+ expect(codeText).toContain('+ npm ci');
14
+ expect(codeText).toContain('- npm install');
15
+ expect(container.querySelectorAll('.console-diff-add').length).toBe(2);
16
+ expect(container.querySelectorAll('.console-diff-del').length).toBe(1);
17
+ expect(container.querySelectorAll('.console-diff-hunk').length).toBe(1);
18
+ });
19
+
20
+ it('shows a placeholder when there is no patch', () => {
21
+ const { getByText } = render(<ConsoleFileDiff patch={null} />);
22
+ expect(getByText('(no diff / binary or too large)')).toBeInTheDocument();
23
+ });
24
+ });
@@ -0,0 +1,30 @@
1
+ import { parseUnifiedDiff } from '../../logic/diff';
2
+
3
+ export type ConsoleFileDiffProps = {
4
+ patch: string | null;
5
+ };
6
+
7
+ export const ConsoleFileDiff = ({ patch }: ConsoleFileDiffProps) => {
8
+ if (patch === null || patch === '') {
9
+ return (
10
+ <p className="console-file-diff-empty">(no diff / binary or too large)</p>
11
+ );
12
+ }
13
+ const rows = parseUnifiedDiff(patch);
14
+ return (
15
+ <table className="console-file-diff">
16
+ <tbody>
17
+ {rows.map((row) => (
18
+ <tr
19
+ key={`${row.kind}:${row.oldLineNumber ?? 'x'}:${row.newLineNumber ?? 'x'}:${row.content}`}
20
+ className={`console-diff-row console-diff-${row.kind}`}
21
+ >
22
+ <td className="console-diff-ln">{row.oldLineNumber ?? ''}</td>
23
+ <td className="console-diff-ln">{row.newLineNumber ?? ''}</td>
24
+ <td className="console-diff-code">{row.content}</td>
25
+ </tr>
26
+ ))}
27
+ </tbody>
28
+ </table>
29
+ );
30
+ };
@@ -34,9 +34,13 @@ export const ConsolePullRequestDetail = ({
34
34
  now,
35
35
  }: ConsolePullRequestSectionProps) => {
36
36
  const summary = pullRequest.summary;
37
+ const filesCount =
38
+ filesAreLoading || filesError !== null ? null : files.length;
39
+ const commitsCount =
40
+ commitsAreLoading || commitsError !== null ? null : commits.length;
37
41
  return (
38
- <section className="console-pr-section">
39
- <header className="console-pr-section-header">
42
+ <>
43
+ <div className="console-pr-header">
40
44
  <a
41
45
  href={pullRequest.url}
42
46
  className="console-pr-section-title"
@@ -48,34 +52,36 @@ export const ConsolePullRequestDetail = ({
48
52
  {pullRequest.isDraft && (
49
53
  <span className="console-pr-section-state">draft</span>
50
54
  )}
51
- </header>
52
- <div className="console-pr-statbar">
53
- {pullRequest.branchName !== null && (
54
- <span className="console-pr-branch">{pullRequest.branchName}</span>
55
- )}
56
- {summary !== null && (
57
- <>
58
- <span className="console-pr-add">+{summary.additions}</span>
59
- <span className="console-pr-del">-{summary.deletions}</span>
60
- <span className="console-pr-files-count">
61
- {summary.changedFiles} files
62
- </span>
63
- </>
64
- )}
55
+ <div className="console-pr-statbar">
56
+ {pullRequest.branchName !== null && (
57
+ <span className="console-pr-branch">{pullRequest.branchName}</span>
58
+ )}
59
+ {summary !== null && (
60
+ <>
61
+ <span className="console-pr-add">+{summary.additions}</span>
62
+ <span className="console-pr-del">-{summary.deletions}</span>
63
+ <span className="console-pr-files-count">
64
+ {summary.changedFiles} files
65
+ </span>
66
+ </>
67
+ )}
68
+ </div>
65
69
  </div>
66
- {bodyIsLoading ? (
67
- <p className="console-pr-body-loading">Loading description...</p>
68
- ) : (
69
- <ConsoleMarkdownContent body={summary?.body ?? body} />
70
- )}
71
- <ConsolePanel title="Changed files">
70
+ <ConsolePanel title="Description" defaultCollapsed>
71
+ {bodyIsLoading ? (
72
+ <p className="console-pr-body-loading">Loading description...</p>
73
+ ) : (
74
+ <ConsoleMarkdownContent body={summary?.body ?? body} />
75
+ )}
76
+ </ConsolePanel>
77
+ <ConsolePanel title="Changed files" count={filesCount}>
72
78
  <ConsoleChangedFileList
73
79
  files={files}
74
80
  isLoading={filesAreLoading}
75
81
  error={filesError}
76
82
  />
77
83
  </ConsolePanel>
78
- <ConsolePanel title="Commits" defaultCollapsed>
84
+ <ConsolePanel title="Commits" count={commitsCount} defaultCollapsed>
79
85
  <ConsoleCommitList
80
86
  commits={commits}
81
87
  isLoading={commitsAreLoading}
@@ -83,6 +89,6 @@ export const ConsolePullRequestDetail = ({
83
89
  now={now}
84
90
  />
85
91
  </ConsolePanel>
86
- </section>
92
+ </>
87
93
  );
88
94
  };
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsoleUndoToast } from './ConsoleUndoToast';
3
+
4
+ const meta: Meta<typeof ConsoleUndoToast> = {
5
+ title: 'Console/ConsoleUndoToast',
6
+ component: ConsoleUndoToast,
7
+ args: {
8
+ message: 'Approved — PR #851',
9
+ color: 'green',
10
+ remainingSeconds: 5,
11
+ progress: 1,
12
+ onUndo: () => {},
13
+ },
14
+ };
15
+
16
+ export default meta;
17
+
18
+ type Story = StoryObj<typeof ConsoleUndoToast>;
19
+
20
+ export const Approve: Story = {};
21
+
22
+ export const Reject: Story = {
23
+ args: {
24
+ message: 'Rejected — PR #853',
25
+ color: 'amber',
26
+ remainingSeconds: 3,
27
+ progress: 0.6,
28
+ },
29
+ };
30
+
31
+ export const StatusChange: Story = {
32
+ args: {
33
+ message: 'Status → Awaiting Workspace — #845',
34
+ color: 'blue',
35
+ remainingSeconds: 2,
36
+ progress: 0.4,
37
+ },
38
+ };
39
+
40
+ export const CloseIssue: Story = {
41
+ args: {
42
+ message: 'Closed — #845',
43
+ color: 'red',
44
+ remainingSeconds: 1,
45
+ progress: 0.2,
46
+ },
47
+ };
@@ -0,0 +1,54 @@
1
+ import { fireEvent, render } from '@testing-library/react';
2
+ import { ConsoleUndoToast } from './ConsoleUndoToast';
3
+
4
+ describe('ConsoleUndoToast', () => {
5
+ const baseProps = {
6
+ message: 'Approved — PR #851',
7
+ color: 'green' as const,
8
+ remainingSeconds: 5,
9
+ progress: 1,
10
+ onUndo: () => {},
11
+ };
12
+
13
+ it('shows the action message and the countdown', () => {
14
+ const { getByText } = render(<ConsoleUndoToast {...baseProps} />);
15
+ expect(getByText('Approved — PR #851')).toBeInTheDocument();
16
+ expect(getByText('5s')).toBeInTheDocument();
17
+ });
18
+
19
+ it('renders the color modifier class', () => {
20
+ const { container } = render(<ConsoleUndoToast {...baseProps} />);
21
+ expect(
22
+ container.querySelector('.console-undo-toast-green'),
23
+ ).toBeInTheDocument();
24
+ });
25
+
26
+ it('shrinks the progress bar with the remaining fraction', () => {
27
+ const { container } = render(
28
+ <ConsoleUndoToast {...baseProps} progress={0.4} />,
29
+ );
30
+ const bar = container.querySelector(
31
+ '.console-undo-toast-bar',
32
+ ) as HTMLElement;
33
+ expect(bar.style.width).toBe('40%');
34
+ });
35
+
36
+ it('clamps the progress bar width within 0 and 100 percent', () => {
37
+ const { container } = render(
38
+ <ConsoleUndoToast {...baseProps} progress={1.4} />,
39
+ );
40
+ const bar = container.querySelector(
41
+ '.console-undo-toast-bar',
42
+ ) as HTMLElement;
43
+ expect(bar.style.width).toBe('100%');
44
+ });
45
+
46
+ it('invokes onUndo when the Undo control is clicked', () => {
47
+ const onUndo = jest.fn();
48
+ const { getByText } = render(
49
+ <ConsoleUndoToast {...baseProps} onUndo={onUndo} />,
50
+ );
51
+ fireEvent.click(getByText('Undo'));
52
+ expect(onUndo).toHaveBeenCalledTimes(1);
53
+ });
54
+ });
@@ -0,0 +1,36 @@
1
+ import type { ConsoleToastColor } from '../../logic/actionToast';
2
+
3
+ export type ConsoleUndoToastProps = {
4
+ message: string;
5
+ color: ConsoleToastColor;
6
+ remainingSeconds: number;
7
+ progress: number;
8
+ onUndo: () => void;
9
+ };
10
+
11
+ export const ConsoleUndoToast = ({
12
+ message,
13
+ color,
14
+ remainingSeconds,
15
+ progress,
16
+ onUndo,
17
+ }: ConsoleUndoToastProps) => (
18
+ <div
19
+ className={`console-undo-toast console-undo-toast-${color}`}
20
+ role="status"
21
+ aria-live="polite"
22
+ >
23
+ <span className="console-undo-toast-message">{message}</span>
24
+ <button type="button" className="console-undo-toast-undo" onClick={onUndo}>
25
+ Undo
26
+ </button>
27
+ <span className="console-undo-toast-countdown" aria-hidden="true">
28
+ {remainingSeconds}s
29
+ </span>
30
+ <span
31
+ className="console-undo-toast-bar"
32
+ aria-hidden="true"
33
+ style={{ width: `${Math.max(0, Math.min(100, progress * 100))}%` }}
34
+ />
35
+ </div>
36
+ );
@@ -0,0 +1,117 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { useConsoleActionQueue } from './useConsoleActionQueue';
3
+
4
+ const makeAction = (
5
+ overrides: Partial<
6
+ Parameters<ReturnType<typeof useConsoleActionQueue>['enqueue']>[0]
7
+ > = {},
8
+ ) => ({
9
+ message: 'Approved — PR #851',
10
+ color: 'green' as const,
11
+ commit: jest.fn(),
12
+ advance: jest.fn(),
13
+ ...overrides,
14
+ });
15
+
16
+ describe('useConsoleActionQueue', () => {
17
+ beforeEach(() => {
18
+ jest.useFakeTimers();
19
+ });
20
+
21
+ afterEach(() => {
22
+ jest.useRealTimers();
23
+ });
24
+
25
+ it('advances immediately but only commits after the five second window', () => {
26
+ const { result } = renderHook(() => useConsoleActionQueue());
27
+ const action = makeAction();
28
+ act(() => {
29
+ result.current.enqueue(action);
30
+ });
31
+ expect(action.advance).toHaveBeenCalledTimes(1);
32
+ expect(action.commit).not.toHaveBeenCalled();
33
+ expect(result.current.pending?.message).toBe('Approved — PR #851');
34
+ expect(result.current.pending?.remainingSeconds).toBe(5);
35
+
36
+ act(() => {
37
+ jest.advanceTimersByTime(4900);
38
+ });
39
+ expect(action.commit).not.toHaveBeenCalled();
40
+
41
+ act(() => {
42
+ jest.advanceTimersByTime(200);
43
+ });
44
+ expect(action.commit).toHaveBeenCalledTimes(1);
45
+ expect(result.current.pending).toBeNull();
46
+ });
47
+
48
+ it('counts the remaining seconds down during the window', () => {
49
+ const { result } = renderHook(() => useConsoleActionQueue());
50
+ act(() => {
51
+ result.current.enqueue(makeAction());
52
+ });
53
+ act(() => {
54
+ jest.advanceTimersByTime(2000);
55
+ });
56
+ expect(result.current.pending?.remainingSeconds).toBe(3);
57
+ expect(result.current.pending?.progress).toBeCloseTo(0.6, 1);
58
+ });
59
+
60
+ it('cancels the command when undo is called within the window', () => {
61
+ const { result } = renderHook(() => useConsoleActionQueue());
62
+ const action = makeAction();
63
+ act(() => {
64
+ result.current.enqueue(action);
65
+ });
66
+ act(() => {
67
+ jest.advanceTimersByTime(2000);
68
+ result.current.undo();
69
+ });
70
+ expect(result.current.pending).toBeNull();
71
+ act(() => {
72
+ jest.advanceTimersByTime(5000);
73
+ });
74
+ expect(action.commit).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it('flushes the previous pending action when a new one is enqueued', () => {
78
+ const { result } = renderHook(() => useConsoleActionQueue());
79
+ const first = makeAction({ message: 'Approved — PR #851' });
80
+ const second = makeAction({
81
+ message: 'Rejected — PR #853',
82
+ color: 'amber',
83
+ });
84
+ act(() => {
85
+ result.current.enqueue(first);
86
+ });
87
+ act(() => {
88
+ jest.advanceTimersByTime(1000);
89
+ result.current.enqueue(second);
90
+ });
91
+ expect(first.commit).toHaveBeenCalledTimes(1);
92
+ expect(second.commit).not.toHaveBeenCalled();
93
+ expect(result.current.pending?.message).toBe('Rejected — PR #853');
94
+ act(() => {
95
+ jest.advanceTimersByTime(5000);
96
+ });
97
+ expect(second.commit).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('does not commit twice if the window elapses after a manual flush', () => {
101
+ const { result } = renderHook(() => useConsoleActionQueue());
102
+ const first = makeAction();
103
+ const second = makeAction({ message: 'Closed — #845', color: 'red' });
104
+ act(() => {
105
+ result.current.enqueue(first);
106
+ });
107
+ act(() => {
108
+ result.current.enqueue(second);
109
+ });
110
+ expect(first.commit).toHaveBeenCalledTimes(1);
111
+ act(() => {
112
+ jest.advanceTimersByTime(6000);
113
+ });
114
+ expect(first.commit).toHaveBeenCalledTimes(1);
115
+ expect(second.commit).toHaveBeenCalledTimes(1);
116
+ });
117
+ });
@@ -0,0 +1,105 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ ACTION_TOAST_DELAY_MS,
4
+ type ConsoleToastColor,
5
+ } from '../logic/actionToast';
6
+
7
+ export type ConsoleQueuedAction = {
8
+ message: string;
9
+ color: ConsoleToastColor;
10
+ commit: () => void;
11
+ advance: () => void;
12
+ };
13
+
14
+ export type ConsolePendingActionView = {
15
+ message: string;
16
+ color: ConsoleToastColor;
17
+ remainingSeconds: number;
18
+ progress: number;
19
+ };
20
+
21
+ const COUNTDOWN_TICK_MS = 100;
22
+
23
+ const computeRemainingSeconds = (elapsedMs: number): number =>
24
+ Math.max(0, Math.ceil((ACTION_TOAST_DELAY_MS - elapsedMs) / 1000));
25
+
26
+ const computeProgress = (elapsedMs: number): number =>
27
+ Math.max(0, 1 - elapsedMs / ACTION_TOAST_DELAY_MS);
28
+
29
+ export type ConsoleActionQueue = {
30
+ pending: ConsolePendingActionView | null;
31
+ enqueue: (action: ConsoleQueuedAction) => void;
32
+ undo: () => void;
33
+ };
34
+
35
+ export const useConsoleActionQueue = (): ConsoleActionQueue => {
36
+ const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
37
+ const actionRef = useRef<ConsoleQueuedAction | null>(null);
38
+ const startRef = useRef<number>(0);
39
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
40
+ const committedRef = useRef<boolean>(false);
41
+
42
+ const clearTimer = useCallback((): void => {
43
+ if (timerRef.current !== null) {
44
+ clearInterval(timerRef.current);
45
+ timerRef.current = null;
46
+ }
47
+ }, []);
48
+
49
+ const commitPending = useCallback((): void => {
50
+ const action = actionRef.current;
51
+ clearTimer();
52
+ actionRef.current = null;
53
+ setPending(null);
54
+ if (action !== null && !committedRef.current) {
55
+ committedRef.current = true;
56
+ action.commit();
57
+ }
58
+ }, [clearTimer]);
59
+
60
+ const undo = useCallback((): void => {
61
+ clearTimer();
62
+ actionRef.current = null;
63
+ committedRef.current = true;
64
+ setPending(null);
65
+ }, [clearTimer]);
66
+
67
+ const enqueue = useCallback(
68
+ (action: ConsoleQueuedAction): void => {
69
+ if (actionRef.current !== null && !committedRef.current) {
70
+ const previous = actionRef.current;
71
+ clearTimer();
72
+ committedRef.current = true;
73
+ previous.commit();
74
+ }
75
+ committedRef.current = false;
76
+ actionRef.current = action;
77
+ startRef.current = Date.now();
78
+ setPending({
79
+ message: action.message,
80
+ color: action.color,
81
+ remainingSeconds: computeRemainingSeconds(0),
82
+ progress: computeProgress(0),
83
+ });
84
+ action.advance();
85
+ timerRef.current = setInterval(() => {
86
+ const elapsed = Date.now() - startRef.current;
87
+ if (elapsed >= ACTION_TOAST_DELAY_MS) {
88
+ commitPending();
89
+ return;
90
+ }
91
+ setPending({
92
+ message: action.message,
93
+ color: action.color,
94
+ remainingSeconds: computeRemainingSeconds(elapsed),
95
+ progress: computeProgress(elapsed),
96
+ });
97
+ }, COUNTDOWN_TICK_MS);
98
+ },
99
+ [clearTimer, commitPending],
100
+ );
101
+
102
+ useEffect(() => clearTimer, [clearTimer]);
103
+
104
+ return { pending, enqueue, undo };
105
+ };