github-issue-tower-defence-management 1.165.1 → 1.166.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 (41) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/entry-points/console/consoleOperationApi.js +44 -1
  3. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleTabNames.js +1 -0
  5. package/bin/adapter/entry-points/console/consoleTabNames.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/index-CrSqMAVR.js +86 -0
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  8. package/bin/adapter/entry-points/console/webServer.js +2 -0
  9. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  10. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +23 -0
  11. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +211 -0
  14. package/src/adapter/entry-points/console/consoleOperationApi.ts +63 -0
  15. package/src/adapter/entry-points/console/consoleTabNames.ts +1 -0
  16. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +61 -0
  17. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +63 -2
  18. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +4 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +1 -0
  20. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.stories.tsx +77 -0
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.test.tsx +196 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryList.tsx +163 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleNavigation.test.ts +1 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +15 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +29 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +1 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +10 -1
  28. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +43 -8
  29. package/src/adapter/entry-points/console/ui-dist/assets/index-CrSqMAVR.js +86 -0
  30. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  31. package/src/adapter/entry-points/console/webServer.ts +3 -0
  32. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +86 -1
  33. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +49 -1
  34. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +1 -0
  35. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  36. package/types/adapter/entry-points/console/consoleTabNames.d.ts.map +1 -1
  37. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  38. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +18 -1
  39. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  40. package/bin/adapter/entry-points/console/ui-dist/assets/index-CHUtr_4X.js +0 -86
  41. package/src/adapter/entry-points/console/ui-dist/assets/index-CHUtr_4X.js +0 -86
@@ -38,6 +38,12 @@ export type ConsoleE2eRequestChangesCall = {
38
38
  inlineCommentLocation: { line: number; side: string } | null;
39
39
  };
40
40
 
41
+ export type ConsoleE2eCreateIssueCall = {
42
+ org: string;
43
+ repo: string;
44
+ title: string;
45
+ };
46
+
41
47
  type ConsoleFixtureListItem = {
42
48
  number: number;
43
49
  title: string;
@@ -275,6 +281,39 @@ export const CONSOLE_E2E_TAB_ITEMS: Record<string, ConsoleFixtureListItem[]> = {
275
281
  ],
276
282
  };
277
283
 
284
+ const CONSOLE_E2E_STORIES_SNAPSHOT = {
285
+ pjcode: CONSOLE_E2E_PJCODE,
286
+ generatedAt: '2026-06-18T01:22:09.000Z',
287
+ stories: [
288
+ {
289
+ storyName: 'TDPM Console port',
290
+ storyOptionId: '1491051e',
291
+ color: 'BLUE',
292
+ openItemCount: 6,
293
+ },
294
+ {
295
+ storyName: 'Publish product documentation site',
296
+ storyOptionId: 'f7cd5cbc',
297
+ color: 'GREEN',
298
+ openItemCount: 1,
299
+ },
300
+ {
301
+ storyName: 'regular / WORKFLOW BLOCKER',
302
+ storyOptionId: 'a3b9c4d2',
303
+ color: 'RED',
304
+ openItemCount: 1,
305
+ },
306
+ ],
307
+ storyOrder: [
308
+ 'TDPM Console port',
309
+ 'regular / workflow improvement',
310
+ 'Publish product documentation site',
311
+ 'regular / WORKFLOW BLOCKER',
312
+ ],
313
+ storyColors: STORY_COLORS,
314
+ defaultNameWithOwner: REPO_NAME_WITH_OWNER,
315
+ };
316
+
278
317
  const writeFixtureData = (consoleDataOutputDir: string): void => {
279
318
  for (const [tab, items] of Object.entries(CONSOLE_E2E_TAB_ITEMS)) {
280
319
  const tabDir = path.join(consoleDataOutputDir, CONSOLE_E2E_PJCODE, tab);
@@ -284,6 +323,16 @@ const writeFixtureData = (consoleDataOutputDir: string): void => {
284
323
  JSON.stringify(buildSnapshot(items)),
285
324
  );
286
325
  }
326
+ const storiesTabDir = path.join(
327
+ consoleDataOutputDir,
328
+ CONSOLE_E2E_PJCODE,
329
+ 'stories',
330
+ );
331
+ fs.mkdirSync(storiesTabDir, { recursive: true });
332
+ fs.writeFileSync(
333
+ path.join(storiesTabDir, 'list.json'),
334
+ JSON.stringify(CONSOLE_E2E_STORIES_SNAPSHOT),
335
+ );
287
336
  };
288
337
 
289
338
  const buildE2eProject = (): Project => ({
@@ -408,13 +457,21 @@ const inlineCommentPullRequestDetail: PullRequestDetail = {
408
457
  const createStubIssueRepository = (
409
458
  reviewCommentCalls: ConsoleE2eReviewCommentCall[],
410
459
  requestChangesCalls: ConsoleE2eRequestChangesCall[],
460
+ createIssueCalls: ConsoleE2eCreateIssueCall[],
411
461
  ): IssueRepository => ({
412
462
  getAllIssues: () => notImplemented('getAllIssues'),
413
463
  getIssueByUrl: async (url: string): Promise<Issue | null> =>
414
464
  buildIssueForUrl(url),
415
465
  getIssueBodyByUrl: async (url: string): Promise<string | null> =>
416
466
  buildIssueForUrl(url)?.body ?? null,
417
- createNewIssue: () => notImplemented('createNewIssue'),
467
+ createNewIssue: async (
468
+ org: string,
469
+ repo: string,
470
+ title: string,
471
+ ): Promise<number> => {
472
+ createIssueCalls.push({ org, repo, title });
473
+ return 9001;
474
+ },
418
475
  searchIssue: () => notImplemented('searchIssue'),
419
476
  updateIssue: () => notImplemented('updateIssue'),
420
477
  updateIssueBody: () => notImplemented('updateIssueBody'),
@@ -507,7 +564,7 @@ const createStubIssueRepository = (
507
564
  createCommentByUrl: async (): Promise<void> => undefined,
508
565
  getAllOpened: () => notImplemented('getAllOpened'),
509
566
  getStoryObjectMap: () => notImplemented('getStoryObjectMap'),
510
- addIssueToProject: () => notImplemented('addIssueToProject'),
567
+ addIssueToProject: async (): Promise<void> => undefined,
511
568
  setDependedIssueUrl: () => notImplemented('setDependedIssueUrl'),
512
569
  getIssueOrPullRequestBody: async (): Promise<string> =>
513
570
  [
@@ -572,6 +629,7 @@ export type ConsoleE2eHarness = {
572
629
  consoleDataOutputDir: string;
573
630
  reviewCommentCalls: ConsoleE2eReviewCommentCall[];
574
631
  requestChangesCalls: ConsoleE2eRequestChangesCall[];
632
+ createIssueCalls: ConsoleE2eCreateIssueCall[];
575
633
  stop: () => Promise<void>;
576
634
  };
577
635
 
@@ -592,6 +650,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
592
650
 
593
651
  const reviewCommentCalls: ConsoleE2eReviewCommentCall[] = [];
594
652
  const requestChangesCalls: ConsoleE2eRequestChangesCall[] = [];
653
+ const createIssueCalls: ConsoleE2eCreateIssueCall[] = [];
595
654
 
596
655
  const server = await startWebServer({
597
656
  accessToken: CONSOLE_E2E_TOKEN,
@@ -600,6 +659,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
600
659
  issueRepository: createStubIssueRepository(
601
660
  reviewCommentCalls,
602
661
  requestChangesCalls,
662
+ createIssueCalls,
603
663
  ),
604
664
  resolveProject,
605
665
  isPjcodeConfigured,
@@ -629,6 +689,7 @@ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
629
689
  consoleDataOutputDir,
630
690
  reviewCommentCalls,
631
691
  requestChangesCalls,
692
+ createIssueCalls,
632
693
  stop: async (): Promise<void> => {
633
694
  await closeServer(server);
634
695
  fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -26,6 +26,7 @@ const counts: Record<ConsoleTabName, number> = {
26
26
  'failed-preparation': 2,
27
27
  'todo-by-human': 66,
28
28
  'todo-by-agent': 24,
29
+ stories: 3,
29
30
  };
30
31
 
31
32
  export const AllTabsWithCounts: Story = {
@@ -46,6 +47,7 @@ export const ZeroCountTabsHidden: Story = {
46
47
  'failed-preparation': 0,
47
48
  'todo-by-human': 0,
48
49
  'todo-by-agent': 0,
50
+ stories: 0,
49
51
  },
50
52
  },
51
53
  };
@@ -61,6 +63,7 @@ export const ZeroCountActiveTabStaysVisible: Story = {
61
63
  'failed-preparation': 0,
62
64
  'todo-by-human': 0,
63
65
  'todo-by-agent': 0,
66
+ stories: 0,
64
67
  },
65
68
  },
66
69
  };
@@ -76,6 +79,7 @@ export const AfterAutoAdvanceToNextTab: Story = {
76
79
  'failed-preparation': 2,
77
80
  'todo-by-human': 4,
78
81
  'todo-by-agent': 0,
82
+ stories: 0,
79
83
  },
80
84
  onSelectTab: () => {},
81
85
  },
@@ -10,6 +10,7 @@ const counts: Record<ConsoleTabName, number> = {
10
10
  'failed-preparation': 0,
11
11
  'todo-by-human': 2,
12
12
  'todo-by-agent': 3,
13
+ stories: 0,
13
14
  };
14
15
 
15
16
  const baseProps = {
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { ConsoleStoryList } from './ConsoleStoryList';
3
+
4
+ const stories = [
5
+ {
6
+ storyName: 'TDPM Console port',
7
+ storyOptionId: '1491051e',
8
+ color: 'BLUE' as const,
9
+ openItemCount: 12,
10
+ },
11
+ {
12
+ storyName: 'Publish product documentation site',
13
+ storyOptionId: 'f7cd5cbc',
14
+ color: 'GREEN' as const,
15
+ openItemCount: 3,
16
+ },
17
+ {
18
+ storyName: 'Move to Okinawa',
19
+ storyOptionId: '564803ee',
20
+ color: 'PURPLE' as const,
21
+ openItemCount: 0,
22
+ },
23
+ {
24
+ storyName: 'regular / regular payment invoice tax',
25
+ storyOptionId: 'd7cdcb61',
26
+ color: 'YELLOW' as const,
27
+ openItemCount: 7,
28
+ },
29
+ {
30
+ storyName: 'regular / WORKFLOW BLOCKER',
31
+ storyOptionId: 'e35b3da2',
32
+ color: 'RED' as const,
33
+ openItemCount: 2,
34
+ },
35
+ ];
36
+
37
+ const meta: Meta<typeof ConsoleStoryList> = {
38
+ title: 'Console/ConsoleStoryList',
39
+ component: ConsoleStoryList,
40
+ args: { onCreateIssue: () => Promise.resolve() },
41
+ };
42
+
43
+ export default meta;
44
+
45
+ type Story = StoryObj<typeof ConsoleStoryList>;
46
+
47
+ export const WithStories: Story = {
48
+ args: {
49
+ stories,
50
+ isLoading: false,
51
+ error: null,
52
+ },
53
+ };
54
+
55
+ export const Loading: Story = {
56
+ args: {
57
+ stories: [],
58
+ isLoading: true,
59
+ error: null,
60
+ },
61
+ };
62
+
63
+ export const Empty: Story = {
64
+ args: {
65
+ stories: [],
66
+ isLoading: false,
67
+ error: null,
68
+ },
69
+ };
70
+
71
+ export const ErrorState: Story = {
72
+ args: {
73
+ stories: [],
74
+ isLoading: false,
75
+ error: 'HTTP 404',
76
+ },
77
+ };
@@ -0,0 +1,196 @@
1
+ import { fireEvent, render, waitFor } from '@testing-library/react';
2
+ import type { ConsoleStoryEntry } from '../../logic/types';
3
+ import { ConsoleStoryList } from './ConsoleStoryList';
4
+
5
+ const storyEntries: ConsoleStoryEntry[] = [
6
+ {
7
+ storyName: 'TDPM Console port',
8
+ storyOptionId: '1491051e',
9
+ color: 'BLUE',
10
+ openItemCount: 12,
11
+ },
12
+ {
13
+ storyName: 'Move to Okinawa',
14
+ storyOptionId: '564803ee',
15
+ color: 'PURPLE',
16
+ openItemCount: 0,
17
+ },
18
+ ];
19
+
20
+ describe('ConsoleStoryList', () => {
21
+ it('renders each story name and its open item count', () => {
22
+ const { getByText } = render(
23
+ <ConsoleStoryList
24
+ stories={storyEntries}
25
+ isLoading={false}
26
+ error={null}
27
+ onCreateIssue={() => Promise.resolve()}
28
+ />,
29
+ );
30
+ expect(getByText('TDPM Console port')).toBeInTheDocument();
31
+ expect(getByText('12')).toBeInTheDocument();
32
+ expect(getByText('Move to Okinawa')).toBeInTheDocument();
33
+ expect(getByText('0')).toBeInTheDocument();
34
+ });
35
+
36
+ it('renders an Add task button for each story row', () => {
37
+ const { getAllByRole } = render(
38
+ <ConsoleStoryList
39
+ stories={storyEntries}
40
+ isLoading={false}
41
+ error={null}
42
+ onCreateIssue={() => Promise.resolve()}
43
+ />,
44
+ );
45
+ const buttons = getAllByRole('button', { name: 'Add task' });
46
+ expect(buttons).toHaveLength(storyEntries.length);
47
+ });
48
+
49
+ it('shows the create form when Add task is clicked', () => {
50
+ const { getAllByRole, getByPlaceholderText } = render(
51
+ <ConsoleStoryList
52
+ stories={storyEntries}
53
+ isLoading={false}
54
+ error={null}
55
+ onCreateIssue={() => Promise.resolve()}
56
+ />,
57
+ );
58
+ const [firstButton] = getAllByRole('button', { name: 'Add task' });
59
+ fireEvent.click(firstButton);
60
+ expect(getByPlaceholderText('Issue title')).toBeInTheDocument();
61
+ });
62
+
63
+ it('hides the form when Add task is clicked again on the same row', () => {
64
+ const { getAllByRole, queryByPlaceholderText } = render(
65
+ <ConsoleStoryList
66
+ stories={storyEntries}
67
+ isLoading={false}
68
+ error={null}
69
+ onCreateIssue={() => Promise.resolve()}
70
+ />,
71
+ );
72
+ const [firstButton] = getAllByRole('button', { name: 'Add task' });
73
+ fireEvent.click(firstButton);
74
+ fireEvent.click(firstButton);
75
+ expect(queryByPlaceholderText('Issue title')).toBeNull();
76
+ });
77
+
78
+ it('calls onCreateIssue with the correct storyOptionId and title', async () => {
79
+ const onCreateIssue = jest.fn().mockResolvedValue(undefined);
80
+ const { getAllByRole, getByPlaceholderText, getByRole } = render(
81
+ <ConsoleStoryList
82
+ stories={storyEntries}
83
+ isLoading={false}
84
+ error={null}
85
+ onCreateIssue={onCreateIssue}
86
+ />,
87
+ );
88
+ fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
89
+ fireEvent.change(getByPlaceholderText('Issue title'), {
90
+ target: { value: 'New feature task' },
91
+ });
92
+ fireEvent.click(getByRole('button', { name: 'Create' }));
93
+ await waitFor(() =>
94
+ expect(onCreateIssue).toHaveBeenCalledWith(
95
+ '1491051e',
96
+ 'New feature task',
97
+ ),
98
+ );
99
+ });
100
+
101
+ it('shows a validation error when Create is clicked with an empty title', () => {
102
+ const { getAllByRole, getByRole, getByText } = render(
103
+ <ConsoleStoryList
104
+ stories={storyEntries}
105
+ isLoading={false}
106
+ error={null}
107
+ onCreateIssue={() => Promise.resolve()}
108
+ />,
109
+ );
110
+ fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
111
+ fireEvent.click(getByRole('button', { name: 'Create' }));
112
+ expect(getByText('Title is required')).toBeInTheDocument();
113
+ });
114
+
115
+ it('closes the form and clears the input after a successful create', async () => {
116
+ const onCreateIssue = jest.fn().mockResolvedValue(undefined);
117
+ const {
118
+ getAllByRole,
119
+ getByPlaceholderText,
120
+ getByRole,
121
+ queryByPlaceholderText,
122
+ } = render(
123
+ <ConsoleStoryList
124
+ stories={storyEntries}
125
+ isLoading={false}
126
+ error={null}
127
+ onCreateIssue={onCreateIssue}
128
+ />,
129
+ );
130
+ fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
131
+ fireEvent.change(getByPlaceholderText('Issue title'), {
132
+ target: { value: 'My task' },
133
+ });
134
+ fireEvent.click(getByRole('button', { name: 'Create' }));
135
+ await waitFor(() =>
136
+ expect(queryByPlaceholderText('Issue title')).toBeNull(),
137
+ );
138
+ });
139
+
140
+ it('shows an API error when onCreateIssue rejects', async () => {
141
+ const onCreateIssue = jest
142
+ .fn()
143
+ .mockRejectedValue(new Error('Network error'));
144
+ const { getAllByRole, getByPlaceholderText, getByRole, getByText } = render(
145
+ <ConsoleStoryList
146
+ stories={storyEntries}
147
+ isLoading={false}
148
+ error={null}
149
+ onCreateIssue={onCreateIssue}
150
+ />,
151
+ );
152
+ fireEvent.click(getAllByRole('button', { name: 'Add task' })[0]);
153
+ fireEvent.change(getByPlaceholderText('Issue title'), {
154
+ target: { value: 'My task' },
155
+ });
156
+ fireEvent.click(getByRole('button', { name: 'Create' }));
157
+ await waitFor(() => expect(getByText('Network error')).toBeInTheDocument());
158
+ });
159
+
160
+ it('shows a loading message when isLoading is true', () => {
161
+ const { getByText } = render(
162
+ <ConsoleStoryList
163
+ stories={[]}
164
+ isLoading={true}
165
+ error={null}
166
+ onCreateIssue={() => Promise.resolve()}
167
+ />,
168
+ );
169
+ expect(getByText('Loading stories...')).toBeInTheDocument();
170
+ });
171
+
172
+ it('shows an empty message when there are no stories', () => {
173
+ const { getByText } = render(
174
+ <ConsoleStoryList
175
+ stories={[]}
176
+ isLoading={false}
177
+ error={null}
178
+ onCreateIssue={() => Promise.resolve()}
179
+ />,
180
+ );
181
+ expect(getByText('No active stories')).toBeInTheDocument();
182
+ });
183
+
184
+ it('shows an error message when error is set', () => {
185
+ const { getByRole, getByText } = render(
186
+ <ConsoleStoryList
187
+ stories={[]}
188
+ isLoading={false}
189
+ error="HTTP 503"
190
+ onCreateIssue={() => Promise.resolve()}
191
+ />,
192
+ );
193
+ expect(getByRole('alert')).toBeInTheDocument();
194
+ expect(getByText(/HTTP 503/)).toBeInTheDocument();
195
+ });
196
+ });
@@ -0,0 +1,163 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { colorFromEnum } from '../../logic/colors';
3
+ import type { ConsoleStoryEntry } from '../../logic/types';
4
+
5
+ type StoryCreateFormProps = {
6
+ storyOptionId: string;
7
+ onSubmit: (storyOptionId: string, title: string) => Promise<void>;
8
+ onCancel: () => void;
9
+ };
10
+
11
+ const StoryCreateForm = ({
12
+ storyOptionId,
13
+ onSubmit,
14
+ onCancel,
15
+ }: StoryCreateFormProps) => {
16
+ const [titleInput, setTitleInput] = useState('');
17
+ const [submitting, setSubmitting] = useState(false);
18
+ const [submitError, setSubmitError] = useState<string | null>(null);
19
+ const inputRef = useRef<HTMLInputElement>(null);
20
+
21
+ useEffect(() => {
22
+ inputRef.current?.focus();
23
+ }, []);
24
+
25
+ const handleSubmit = async (): Promise<void> => {
26
+ const trimmed = titleInput.trim();
27
+ if (trimmed.length === 0) {
28
+ setSubmitError('Title is required');
29
+ return;
30
+ }
31
+ setSubmitting(true);
32
+ setSubmitError(null);
33
+ try {
34
+ await onSubmit(storyOptionId, trimmed);
35
+ } catch (err) {
36
+ setSubmitError(err instanceof Error ? err.message : String(err));
37
+ } finally {
38
+ setSubmitting(false);
39
+ }
40
+ };
41
+
42
+ return (
43
+ <form
44
+ className="console-story-create-form"
45
+ onSubmit={(event) => {
46
+ event.preventDefault();
47
+ void handleSubmit();
48
+ }}
49
+ >
50
+ <input
51
+ ref={inputRef}
52
+ type="text"
53
+ className="console-story-create-input"
54
+ placeholder="Issue title"
55
+ value={titleInput}
56
+ onChange={(e) => setTitleInput(e.target.value)}
57
+ disabled={submitting}
58
+ />
59
+ <button type="submit" className="console-op-button" disabled={submitting}>
60
+ {submitting ? 'Creating…' : 'Create'}
61
+ </button>
62
+ <button
63
+ type="button"
64
+ className="console-op-button"
65
+ onClick={onCancel}
66
+ disabled={submitting}
67
+ >
68
+ Cancel
69
+ </button>
70
+ {submitError !== null && (
71
+ <p role="alert" className="console-list-error">
72
+ {submitError}
73
+ </p>
74
+ )}
75
+ </form>
76
+ );
77
+ };
78
+
79
+ export type ConsoleStoryListProps = {
80
+ stories: ConsoleStoryEntry[];
81
+ isLoading: boolean;
82
+ error: string | null;
83
+ onCreateIssue: (storyOptionId: string, title: string) => Promise<void>;
84
+ };
85
+
86
+ export const ConsoleStoryList = ({
87
+ stories,
88
+ isLoading,
89
+ error,
90
+ onCreateIssue,
91
+ }: ConsoleStoryListProps) => {
92
+ const [expandedOptionId, setExpandedOptionId] = useState<string | null>(null);
93
+
94
+ if (error !== null) {
95
+ return (
96
+ <p role="alert" className="console-list-message console-list-error">
97
+ Failed to load stories: {error}
98
+ </p>
99
+ );
100
+ }
101
+
102
+ if (isLoading) {
103
+ return <p className="console-list-message">Loading stories...</p>;
104
+ }
105
+
106
+ if (stories.length === 0) {
107
+ return <p className="console-list-empty">No active stories</p>;
108
+ }
109
+
110
+ const handleAddClick = (storyOptionId: string): void => {
111
+ setExpandedOptionId(
112
+ expandedOptionId === storyOptionId ? null : storyOptionId,
113
+ );
114
+ };
115
+
116
+ const handleSubmit = async (
117
+ storyOptionId: string,
118
+ title: string,
119
+ ): Promise<void> => {
120
+ await onCreateIssue(storyOptionId, title);
121
+ setExpandedOptionId(null);
122
+ };
123
+
124
+ return (
125
+ <ul className="console-story-list">
126
+ {stories.map((entry) => {
127
+ const palette = colorFromEnum(entry.color);
128
+ const isExpanded = expandedOptionId === entry.storyOptionId;
129
+ return (
130
+ <li key={entry.storyOptionId} className="console-story-list-row">
131
+ <div className="console-story-list-row-main">
132
+ <span
133
+ className="console-storytag"
134
+ style={{ color: palette.fg, borderColor: palette.border }}
135
+ >
136
+ <span
137
+ className="console-story-dot"
138
+ style={{ backgroundColor: palette.dot }}
139
+ />
140
+ {entry.storyName}
141
+ </span>
142
+ <span className="console-story-count">{entry.openItemCount}</span>
143
+ <button
144
+ type="button"
145
+ className="console-op-button"
146
+ onClick={() => handleAddClick(entry.storyOptionId)}
147
+ >
148
+ Add task
149
+ </button>
150
+ </div>
151
+ {isExpanded && (
152
+ <StoryCreateForm
153
+ storyOptionId={entry.storyOptionId}
154
+ onSubmit={handleSubmit}
155
+ onCancel={() => setExpandedOptionId(null)}
156
+ />
157
+ )}
158
+ </li>
159
+ );
160
+ })}
161
+ </ul>
162
+ );
163
+ };
@@ -16,6 +16,7 @@ const counts = (
16
16
  'failed-preparation': 0,
17
17
  'todo-by-human': 0,
18
18
  'todo-by-agent': 0,
19
+ stories: 0,
19
20
  ...overrides,
20
21
  });
21
22
 
@@ -3,6 +3,7 @@ import type {
3
3
  ConsoleFieldOption,
4
4
  ConsoleListItem,
5
5
  ConsoleStoryColorSource,
6
+ ConsoleStoryEntry,
6
7
  ConsoleTabName,
7
8
  } from '../logic/types';
8
9
  import { CONSOLE_TABS } from '../logic/types';
@@ -13,6 +14,8 @@ export type ConsoleTabSnapshot = {
13
14
  statusOptions: ConsoleFieldOption[];
14
15
  storyOptions: ConsoleFieldOption[];
15
16
  storyColors: ConsoleStoryColorSource;
17
+ stories: ConsoleStoryEntry[];
18
+ defaultNameWithOwner: string | null;
16
19
  };
17
20
 
18
21
  export type ConsoleTabDataState = {
@@ -51,6 +54,13 @@ const parseOptions = (value: unknown): ConsoleFieldOption[] => {
51
54
  return value.filter(isRecord) as unknown as ConsoleFieldOption[];
52
55
  };
53
56
 
57
+ const parseStoryEntries = (value: unknown): ConsoleStoryEntry[] => {
58
+ if (!Array.isArray(value)) {
59
+ return [];
60
+ }
61
+ return value.filter(isRecord) as unknown as ConsoleStoryEntry[];
62
+ };
63
+
54
64
  const parseSnapshot = (payload: unknown): ConsoleTabSnapshot => ({
55
65
  items: parseItems(payload),
56
66
  generatedAt:
@@ -63,6 +73,11 @@ const parseSnapshot = (payload: unknown): ConsoleTabSnapshot => ({
63
73
  isRecord(payload) && isRecord(payload.storyColors)
64
74
  ? (payload.storyColors as ConsoleStoryColorSource)
65
75
  : {},
76
+ stories: isRecord(payload) ? parseStoryEntries(payload.stories) : [],
77
+ defaultNameWithOwner:
78
+ isRecord(payload) && typeof payload.defaultNameWithOwner === 'string'
79
+ ? payload.defaultNameWithOwner
80
+ : null,
66
81
  });
67
82
 
68
83
  const emptySnapshots = (): Record<
@@ -339,3 +339,32 @@ export const postConsoleReviewComment = async (
339
339
  throw new Error(await readOperationErrorReason(response));
340
340
  }
341
341
  };
342
+
343
+ export const CREATE_ISSUE_OPERATION_PATH = '/api/createissue';
344
+
345
+ export type ConsoleCreateIssueRequest = {
346
+ pjcode: string;
347
+ title: string;
348
+ storyOptionId: string;
349
+ nameWithOwner: string;
350
+ };
351
+
352
+ export const postConsoleCreateIssue = async (
353
+ request: ConsoleCreateIssueRequest,
354
+ ): Promise<string> => {
355
+ const response = await fetch(CREATE_ISSUE_OPERATION_PATH, {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify(request),
359
+ });
360
+ if (!response.ok) {
361
+ throw new Error(await readOperationErrorReason(response));
362
+ }
363
+ const payload: unknown = await response.json();
364
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
365
+ v !== null && typeof v === 'object' && !Array.isArray(v);
366
+ if (!isRecord(payload) || typeof payload.issueUrl !== 'string') {
367
+ throw new Error('issueUrl was not returned');
368
+ }
369
+ return payload.issueUrl;
370
+ };
@@ -14,6 +14,7 @@ const counts = (
14
14
  'failed-preparation': 0,
15
15
  'todo-by-human': 0,
16
16
  'todo-by-agent': 0,
17
+ stories: 0,
17
18
  ...overrides,
18
19
  });
19
20