github-issue-tower-defence-management 1.91.3 → 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 (51) hide show
  1. package/CHANGELOG.md +7 -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.ts +34 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +5 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +8 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +26 -9
  42. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +21 -20
  43. package/src/adapter/entry-points/console/ui/src/index.css +241 -39
  44. package/src/adapter/entry-points/console/ui-dist/assets/{index-BU6p3cGU.css → index-0IuY3q4G.css} +1 -1
  45. package/src/adapter/entry-points/console/ui-dist/assets/index-D04N09aG.js +100 -0
  46. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  47. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -0
  48. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  49. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  50. package/bin/adapter/entry-points/console/ui-dist/assets/index-BvuSQN9s.js +0 -100
  51. package/src/adapter/entry-points/console/ui-dist/assets/index-Cn4xr5-h.js +0 -100
@@ -0,0 +1,111 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { CONSOLE_TABS, type ConsoleTabName } from '../logic/types';
3
+
4
+ const TAB_NAMES = new Set<string>(CONSOLE_TABS.map((tab) => tab.name));
5
+
6
+ export const parseTabFromPath = (pathname: string): ConsoleTabName | null => {
7
+ const segments = pathname.split('/').filter((segment) => segment.length > 0);
8
+ if (segments.length < 3 || segments[0] !== 'projects') {
9
+ return null;
10
+ }
11
+ const candidate = segments[2];
12
+ return TAB_NAMES.has(candidate) ? (candidate as ConsoleTabName) : null;
13
+ };
14
+
15
+ export const parseItemKeyFromHash = (hash: string): string | null => {
16
+ const prefix = '#item/';
17
+ if (!hash.startsWith(prefix)) {
18
+ return null;
19
+ }
20
+ const encoded = hash.slice(prefix.length);
21
+ if (encoded.length === 0) {
22
+ return null;
23
+ }
24
+ return decodeURIComponent(encoded);
25
+ };
26
+
27
+ export type ConsoleNavigation = {
28
+ activeTab: ConsoleTabName;
29
+ selectedItemKey: string | null;
30
+ tabHref: (tab: ConsoleTabName) => string;
31
+ selectTab: (tab: ConsoleTabName) => void;
32
+ openItem: (itemKey: string) => void;
33
+ closeItem: () => void;
34
+ };
35
+
36
+ export const useConsoleNavigation = (
37
+ pjcode: string | null,
38
+ ): ConsoleNavigation => {
39
+ const readState = useCallback((): {
40
+ activeTab: ConsoleTabName;
41
+ selectedItemKey: string | null;
42
+ } => {
43
+ if (typeof window === 'undefined') {
44
+ return { activeTab: CONSOLE_TABS[0].name, selectedItemKey: null };
45
+ }
46
+ return {
47
+ activeTab:
48
+ parseTabFromPath(window.location.pathname) ?? CONSOLE_TABS[0].name,
49
+ selectedItemKey: parseItemKeyFromHash(window.location.hash),
50
+ };
51
+ }, []);
52
+
53
+ const [state, setState] = useState(readState);
54
+
55
+ useEffect(() => {
56
+ if (typeof window === 'undefined') {
57
+ return;
58
+ }
59
+ const sync = (): void => {
60
+ setState(readState());
61
+ };
62
+ window.addEventListener('popstate', sync);
63
+ window.addEventListener('hashchange', sync);
64
+ return () => {
65
+ window.removeEventListener('popstate', sync);
66
+ window.removeEventListener('hashchange', sync);
67
+ };
68
+ }, [readState]);
69
+
70
+ const tabHref = useCallback(
71
+ (tab: ConsoleTabName): string =>
72
+ pjcode === null ? `#${tab}` : `/projects/${pjcode}/${tab}`,
73
+ [pjcode],
74
+ );
75
+
76
+ const selectTab = useCallback(
77
+ (tab: ConsoleTabName): void => {
78
+ if (typeof window === 'undefined') {
79
+ setState({ activeTab: tab, selectedItemKey: null });
80
+ return;
81
+ }
82
+ window.history.pushState({}, '', tabHref(tab));
83
+ setState({ activeTab: tab, selectedItemKey: null });
84
+ },
85
+ [tabHref],
86
+ );
87
+
88
+ const openItem = useCallback((itemKey: string): void => {
89
+ if (typeof window !== 'undefined') {
90
+ window.location.hash = `#item/${encodeURIComponent(itemKey)}`;
91
+ }
92
+ setState((current) => ({ ...current, selectedItemKey: itemKey }));
93
+ }, []);
94
+
95
+ const closeItem = useCallback((): void => {
96
+ if (typeof window !== 'undefined' && window.location.hash !== '') {
97
+ const url = `${window.location.pathname}${window.location.search}`;
98
+ window.history.pushState({}, '', url);
99
+ }
100
+ setState((current) => ({ ...current, selectedItemKey: null }));
101
+ }, []);
102
+
103
+ return {
104
+ activeTab: state.activeTab,
105
+ selectedItemKey: state.selectedItemKey,
106
+ tabHref,
107
+ selectTab,
108
+ openItem,
109
+ closeItem,
110
+ };
111
+ };
@@ -177,6 +177,43 @@ describe('useConsoleOperations', () => {
177
177
  expect(stored[issueItem.projectItemId].done).toBe(true);
178
178
  });
179
179
 
180
+ it('posts a comment to the comment endpoint and returns the created comment', async () => {
181
+ const fetchMock: jest.Mock = jest.fn(async () => ({
182
+ ok: true,
183
+ status: 200,
184
+ json: async () => ({
185
+ ok: true,
186
+ comment: {
187
+ author: 'HiromiShikata',
188
+ body: 'Thanks for the parity fix.',
189
+ createdAt: '2026-06-18T03:21:00.000Z',
190
+ },
191
+ }),
192
+ }));
193
+ global.fetch = fetchMock as unknown as typeof fetch;
194
+ const { result } = setup();
195
+ let created: Awaited<
196
+ ReturnType<typeof result.current.operations.addComment>
197
+ > | null = null;
198
+ await act(async () => {
199
+ created = await result.current.operations.addComment(
200
+ issueItem,
201
+ 'Thanks for the parity fix.',
202
+ );
203
+ });
204
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/comment?k=token');
205
+ expect(lastBody(fetchMock)).toMatchObject({
206
+ pjcode: 'umino',
207
+ url: issueItem.url,
208
+ body: 'Thanks for the parity fix.',
209
+ });
210
+ expect(created).toEqual({
211
+ author: 'HiromiShikata',
212
+ body: 'Thanks for the parity fix.',
213
+ createdAt: '2026-06-18T03:21:00.000Z',
214
+ });
215
+ });
216
+
180
217
  it('rejects an operation and posts nothing when no pjcode is available', async () => {
181
218
  const fetchMock = captureFetch();
182
219
  localStorage.clear();
@@ -3,6 +3,7 @@ import {
3
3
  type ConsoleIntmuxRequest,
4
4
  type ConsoleReviewRequest,
5
5
  type ConsoleTriageRequest,
6
+ postConsoleComment,
6
7
  postConsoleOperation,
7
8
  } from '../lib/consoleApi';
8
9
  import {
@@ -14,6 +15,7 @@ import {
14
15
  } from '../logic/operations';
15
16
  import { overlayKeyForItem } from '../logic/overlay';
16
17
  import type {
18
+ ConsoleComment,
17
19
  ConsoleFieldOption,
18
20
  ConsoleListItem,
19
21
  ConsoleTabName,
@@ -51,6 +53,7 @@ export type ConsoleOperationsApi = {
51
53
  item: ConsoleListItem,
52
54
  action: ConsoleCloseAction,
53
55
  ) => Promise<void>;
56
+ addComment: (item: ConsoleListItem, body: string) => Promise<ConsoleComment>;
54
57
  };
55
58
 
56
59
  const reviewRequest = (
@@ -232,6 +235,20 @@ export const useConsoleOperations = (
232
235
  [pjcode, appendToken, markDone],
233
236
  );
234
237
 
238
+ const addComment = useCallback(
239
+ async (item: ConsoleListItem, body: string) => {
240
+ if (pjcode === null) {
241
+ throw missingPjcodeError();
242
+ }
243
+ return postConsoleComment(appendToken, {
244
+ pjcode,
245
+ url: item.url,
246
+ body,
247
+ });
248
+ },
249
+ [pjcode, appendToken],
250
+ );
251
+
235
252
  return {
236
253
  reviewPullRequest,
237
254
  setNextActionDate,
@@ -239,5 +256,6 @@ export const useConsoleOperations = (
239
256
  setStatus,
240
257
  setInTmuxByHuman,
241
258
  closeIssue,
259
+ addComment,
242
260
  };
243
261
  };
@@ -185,3 +185,37 @@ export const postConsoleOperation = async (
185
185
  throw new Error(`HTTP ${response.status}`);
186
186
  }
187
187
  };
188
+
189
+ export const COMMENT_OPERATION_PATH = '/api/comment';
190
+
191
+ export type ConsoleCommentRequest = {
192
+ pjcode: string;
193
+ url: string;
194
+ body: string;
195
+ };
196
+
197
+ const parsePostedComment = (payload: unknown): ConsoleComment => {
198
+ if (!isRecord(payload) || !isRecord(payload.comment)) {
199
+ throw new Error('comment was not returned');
200
+ }
201
+ return {
202
+ author: getString(payload.comment.author),
203
+ body: getString(payload.comment.body),
204
+ createdAt: getString(payload.comment.createdAt),
205
+ };
206
+ };
207
+
208
+ export const postConsoleComment = async (
209
+ appendToken: AppendToken,
210
+ request: ConsoleCommentRequest,
211
+ ): Promise<ConsoleComment> => {
212
+ const response = await fetch(appendToken(COMMENT_OPERATION_PATH), {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: JSON.stringify(request),
216
+ });
217
+ if (!response.ok) {
218
+ throw new Error(`HTTP ${response.status}`);
219
+ }
220
+ return parsePostedComment(await response.json());
221
+ };
@@ -47,6 +47,11 @@ const buildOperations = (): ConsoleOperationsApi => ({
47
47
  setStatus: jest.fn(async () => {}),
48
48
  setInTmuxByHuman: jest.fn(async () => {}),
49
49
  closeIssue: jest.fn(async () => {}),
50
+ addComment: jest.fn(async () => ({
51
+ author: 'HiromiShikata',
52
+ body: 'comment body',
53
+ createdAt: '2026-06-19T11:58:00.000Z',
54
+ })),
50
55
  });
51
56
 
52
57
  describe('ConsoleItemDetailContainer', () => {
@@ -1,3 +1,4 @@
1
+ import { ConsoleCommentComposer } from '../components/detail/ConsoleCommentComposer';
1
2
  import { ConsoleItemDetail } from '../components/detail/ConsoleItemDetail';
2
3
  import { ConsoleOperationMenu } from '../components/operations/ConsoleOperationMenu';
3
4
  import type { ConsoleCaches } from '../hooks/useConsoleCaches';
@@ -94,6 +95,13 @@ export const ConsoleItemDetailContainer = ({
94
95
  commitsError={detail.commitsError}
95
96
  relatedPullRequests={detail.relatedPullRequests}
96
97
  now={now}
98
+ commentComposer={
99
+ <ConsoleCommentComposer
100
+ isPr={item.isPr}
101
+ now={now}
102
+ onSubmit={(body) => operations.addComment(item, body)}
103
+ />
104
+ }
97
105
  operationBar={
98
106
  <ConsoleOperationMenu
99
107
  tab={tab}
@@ -84,8 +84,17 @@ describe('ConsolePage', () => {
84
84
  expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
85
85
  });
86
86
  fireEvent.click(getByText('Add serveConsole subcommand'));
87
- expect(await findByText('← Back to list')).toBeInTheDocument();
88
- expect(getByText('Approve')).toBeInTheDocument();
87
+ expect(await findByText('Approve')).toBeInTheDocument();
88
+ expect(window.location.hash).toBe('#item/PVTI_1');
89
+ });
90
+
91
+ it('renders the per-tab count sub-heading and snapshot time', async () => {
92
+ const { getByText } = render(<ConsolePage />);
93
+ await waitFor(() => {
94
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
95
+ });
96
+ expect(getByText('1 items awaiting quality check')).toBeInTheDocument();
97
+ expect(getByText('snapshot: 2026-06-19T00:00:00.000Z')).toBeInTheDocument();
89
98
  });
90
99
 
91
100
  it('keeps a tab driven to zero at zero and does not revive its badge after switching tabs', async () => {
@@ -95,18 +104,18 @@ describe('ConsolePage', () => {
95
104
  });
96
105
  expect(
97
106
  getByText('Awaiting Quality Check')
98
- .closest('button')
107
+ .closest('a')
99
108
  ?.querySelector('.console-tab-badge')?.textContent,
100
109
  ).toBe('1');
101
110
 
102
111
  fireEvent.click(getByText('Add serveConsole subcommand'));
103
- expect(await findByText('← Back to list')).toBeInTheDocument();
112
+ expect(await findByText('Approve')).toBeInTheDocument();
104
113
  fireEvent.click(getByText('Approve'));
105
114
 
106
115
  await waitFor(() => {
107
116
  expect(
108
117
  getByText('Awaiting Quality Check')
109
- .closest('button')
118
+ .closest('a')
110
119
  ?.querySelector('.console-tab-badge')?.textContent,
111
120
  ).toBe('0');
112
121
  });
@@ -118,11 +127,19 @@ describe('ConsolePage', () => {
118
127
  ).toBeInTheDocument();
119
128
  });
120
129
 
121
- const prsTabButton = queryByText('Awaiting Quality Check')?.closest(
122
- 'button',
123
- );
130
+ const prsTabLink = queryByText('Awaiting Quality Check')?.closest('a');
124
131
  const prsBadge =
125
- prsTabButton?.querySelector('.console-tab-badge')?.textContent ?? '0';
132
+ prsTabLink?.querySelector('.console-tab-badge')?.textContent ?? '0';
126
133
  expect(prsBadge).toBe('0');
127
134
  });
135
+
136
+ it('shows every tab even when its count is zero', async () => {
137
+ const { getByText, queryByText } = render(<ConsolePage />);
138
+ await waitFor(() => {
139
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
140
+ });
141
+ expect(queryByText('Triage')).not.toBeNull();
142
+ expect(queryByText('Failed Preparation')).not.toBeNull();
143
+ expect(queryByText('Todo by human')).not.toBeNull();
144
+ });
128
145
  });
@@ -1,8 +1,9 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useMemo } from 'react';
2
2
  import { ConsoleProjectSummary } from '../components/layout/ConsoleProjectSummary';
3
3
  import { ConsoleTabList } from '../components/layout/ConsoleTabList';
4
4
  import { ConsoleItemList } from '../components/list/ConsoleItemList';
5
5
  import { useConsoleCaches } from '../hooks/useConsoleCaches';
6
+ import { useConsoleNavigation } from '../hooks/useConsoleNavigation';
6
7
  import { useConsoleOperations } from '../hooks/useConsoleOperations';
7
8
  import { useConsoleOverlay } from '../hooks/useConsoleOverlay';
8
9
  import { useConsolePjcode } from '../hooks/useConsolePjcode';
@@ -34,12 +35,8 @@ const OVERLAY_NAMESPACE_FALLBACK = 'console';
34
35
  export const ConsolePage = () => {
35
36
  const pjcode = useConsolePjcode();
36
37
  const { snapshots, isLoading, error } = useConsoleTabData(pjcode);
37
- const [activeTab, setActiveTab] = useState<ConsoleTabName>(
38
- CONSOLE_TABS[0].name,
39
- );
40
- const [selectedItem, setSelectedItem] = useState<ConsoleListItem | null>(
41
- null,
42
- );
38
+ const navigation = useConsoleNavigation(pjcode);
39
+ const { activeTab, selectedItemKey } = navigation;
43
40
 
44
41
  const overlayState = useConsoleOverlay(pjcode ?? OVERLAY_NAMESPACE_FALLBACK);
45
42
  const caches = useConsoleCaches();
@@ -77,11 +74,18 @@ export const ConsolePage = () => {
77
74
  const storyColors = activeSnapshot?.storyColors ?? {};
78
75
  const statusOptions = activeSnapshot?.statusOptions ?? [];
79
76
  const storyOptions = activeSnapshot?.storyOptions ?? [];
77
+ const generatedAt = activeSnapshot?.generatedAt ?? null;
80
78
 
81
- const selectTab = (tab: ConsoleTabName): void => {
82
- setActiveTab(tab);
83
- setSelectedItem(null);
84
- };
79
+ const selectedItem = useMemo<ConsoleListItem | null>(() => {
80
+ if (selectedItemKey === null || activeSnapshot === null) {
81
+ return null;
82
+ }
83
+ return (
84
+ activeSnapshot.items.find(
85
+ (item) => item.projectItemId === selectedItemKey,
86
+ ) ?? null
87
+ );
88
+ }, [selectedItemKey, activeSnapshot]);
85
89
 
86
90
  const overlayStatusForSelected = ((): ConsoleOverlayStatus | null => {
87
91
  if (selectedItem === null) {
@@ -102,26 +106,23 @@ export const ConsolePage = () => {
102
106
  <ConsoleTabList
103
107
  activeTab={activeTab}
104
108
  counts={counts}
105
- onSelectTab={selectTab}
109
+ pjcode={pjcode}
110
+ generatedAt={generatedAt}
111
+ tabHref={navigation.tabHref}
112
+ onSelectTab={navigation.selectTab}
106
113
  />
107
114
  {selectedItem === null ? (
108
115
  <ConsoleItemList
109
116
  rows={rows}
110
117
  storyColors={storyColors}
111
118
  activeItemId={null}
119
+ now={now}
112
120
  isLoading={isLoading}
113
121
  error={error}
114
- onSelectItem={setSelectedItem}
122
+ onSelectItem={(item) => navigation.openItem(item.projectItemId)}
115
123
  />
116
124
  ) : (
117
125
  <div className="console-detail-screen">
118
- <button
119
- type="button"
120
- className="console-back-button"
121
- onClick={() => setSelectedItem(null)}
122
- >
123
- ← Back to list
124
- </button>
125
126
  <ConsoleItemDetailContainer
126
127
  tab={activeTab}
127
128
  item={selectedItem}