github-issue-tower-defence-management 1.94.4 → 1.96.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 (47) hide show
  1. package/.github/workflows/console-ui.yml +17 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +11 -0
  4. package/bin/adapter/entry-points/cli/index.js +21 -0
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/index-B3F4E8LD.js +101 -0
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  8. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +77 -0
  9. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -0
  10. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +139 -0
  11. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -0
  12. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +59 -0
  13. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -0
  14. package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js +3 -0
  15. package/bin/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.js.map +1 -0
  16. package/package.json +4 -1
  17. package/src/adapter/entry-points/cli/index.ts +38 -0
  18. package/src/adapter/entry-points/console/ui/biome.json +7 -1
  19. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +93 -0
  20. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +380 -0
  21. package/src/adapter/entry-points/console/ui/e2e/playwright.config.ts +30 -0
  22. package/src/adapter/entry-points/console/ui/e2e/tsconfig.json +18 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +14 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.test.ts +48 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/logic/tabAdvance.ts +19 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +39 -6
  27. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +31 -4
  28. package/src/adapter/entry-points/console/ui-dist/assets/index-B3F4E8LD.js +101 -0
  29. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  30. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +268 -0
  31. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +116 -0
  32. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +146 -0
  33. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +119 -0
  34. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +147 -0
  35. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +100 -0
  36. package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +8 -0
  37. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  38. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +20 -0
  39. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -0
  40. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +11 -0
  41. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -0
  42. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts +23 -0
  43. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -0
  44. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +8 -0
  45. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts.map +1 -0
  46. package/bin/adapter/entry-points/console/ui-dist/assets/index-fKvnL036.js +0 -101
  47. package/src/adapter/entry-points/console/ui-dist/assets/index-fKvnL036.js +0 -101
@@ -0,0 +1,380 @@
1
+ import * as fs from 'node:fs';
2
+ import type * as http from 'node:http';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import type { Issue } from '../../../../../domain/entities/Issue';
6
+ import type { Project } from '../../../../../domain/entities/Project';
7
+ import type {
8
+ IssueComment,
9
+ IssueRepository,
10
+ PullRequestCommit,
11
+ PullRequestDetail,
12
+ RelatedPullRequest,
13
+ } from '../../../../../domain/usecases/adapter-interfaces/IssueRepository';
14
+ import type { ConsoleProjectBinding } from '../../consoleOperationApi';
15
+ import { IssueTitleStateCache } from '../../consoleReadApi';
16
+ import { startConsoleServer } from '../../consoleServer';
17
+
18
+ export const CONSOLE_E2E_PJCODE = 'umino';
19
+ export const CONSOLE_E2E_TOKEN = 'console-e2e-fixture-token-3f9c1a';
20
+
21
+ type ConsoleFixtureListItem = {
22
+ number: number;
23
+ title: string;
24
+ url: string;
25
+ repo: string;
26
+ nameWithOwner: string;
27
+ projectItemId: string;
28
+ itemId: string;
29
+ isPr: boolean;
30
+ story: string;
31
+ labels: string[];
32
+ createdAt: string;
33
+ };
34
+
35
+ type ConsoleFixtureFieldOption = {
36
+ id: string;
37
+ name: string;
38
+ color: string;
39
+ };
40
+
41
+ type ConsoleFixtureSnapshot = {
42
+ pjcode: string;
43
+ generatedAt: string;
44
+ statusOptions: ConsoleFixtureFieldOption[];
45
+ storyOptions: ConsoleFixtureFieldOption[];
46
+ storyColors: Record<string, { color: string }>;
47
+ items: ConsoleFixtureListItem[];
48
+ };
49
+
50
+ const REPO_NAME_WITH_OWNER =
51
+ 'HiromiShikata/npm-cli-github-issue-tower-defence-management';
52
+
53
+ const AWAITING_WORKSPACE_OPTION: ConsoleFixtureFieldOption = {
54
+ id: 'd1c19cce',
55
+ name: 'Awaiting Workspace',
56
+ color: 'BLUE',
57
+ };
58
+
59
+ const STATUS_OPTIONS: ConsoleFixtureFieldOption[] = [
60
+ { id: 'f75ad846', name: 'Unread', color: 'ORANGE' },
61
+ AWAITING_WORKSPACE_OPTION,
62
+ { id: 'f57f1ce9', name: 'Preparation', color: 'YELLOW' },
63
+ { id: 'fd313492', name: 'Failed Preparation', color: 'RED' },
64
+ { id: 'e9931e57', name: 'Todo by human', color: 'PINK' },
65
+ { id: 'c2d278b2', name: 'In Tmux by human', color: 'RED' },
66
+ { id: 'e9f6a726', name: 'In Tmux by agent', color: 'YELLOW' },
67
+ ];
68
+
69
+ const STORY_OPTIONS: ConsoleFixtureFieldOption[] = [
70
+ { id: '1491051e', name: 'TDPM Console port', color: 'BLUE' },
71
+ { id: '28415d6c', name: 'regular / workflow improvement', color: 'GRAY' },
72
+ {
73
+ id: 'f7cd5cbc',
74
+ name: 'Publish product documentation site',
75
+ color: 'GREEN',
76
+ },
77
+ ];
78
+
79
+ const STORY_COLORS: Record<string, { color: string }> = {
80
+ 'TDPM Console port': { color: 'BLUE' },
81
+ 'regular / workflow improvement': { color: 'GRAY' },
82
+ 'Publish product documentation site': { color: 'GREEN' },
83
+ };
84
+
85
+ const issueItem = (
86
+ number: number,
87
+ title: string,
88
+ projectItemSuffix: string,
89
+ story: string,
90
+ createdAt: string,
91
+ ): ConsoleFixtureListItem => ({
92
+ number,
93
+ title,
94
+ url: `https://github.com/${REPO_NAME_WITH_OWNER}/issues/${number}`,
95
+ repo: REPO_NAME_WITH_OWNER,
96
+ nameWithOwner: REPO_NAME_WITH_OWNER,
97
+ projectItemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
98
+ itemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
99
+ isPr: false,
100
+ story,
101
+ labels: [],
102
+ createdAt,
103
+ });
104
+
105
+ const pullRequestItem = (
106
+ number: number,
107
+ title: string,
108
+ projectItemSuffix: string,
109
+ story: string,
110
+ createdAt: string,
111
+ ): ConsoleFixtureListItem => ({
112
+ number,
113
+ title,
114
+ url: `https://github.com/${REPO_NAME_WITH_OWNER}/pull/${number}`,
115
+ repo: REPO_NAME_WITH_OWNER,
116
+ nameWithOwner: REPO_NAME_WITH_OWNER,
117
+ projectItemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
118
+ itemId: `PVTI_lADOABCD1234zg${projectItemSuffix}`,
119
+ isPr: true,
120
+ story,
121
+ labels: ['claude'],
122
+ createdAt,
123
+ });
124
+
125
+ const buildSnapshot = (
126
+ items: ConsoleFixtureListItem[],
127
+ ): ConsoleFixtureSnapshot => ({
128
+ pjcode: CONSOLE_E2E_PJCODE,
129
+ generatedAt: '2026-06-18T01:22:09.000Z',
130
+ statusOptions: STATUS_OPTIONS,
131
+ storyOptions: STORY_OPTIONS,
132
+ storyColors: STORY_COLORS,
133
+ items,
134
+ });
135
+
136
+ export const CONSOLE_E2E_TAB_ITEMS: Record<string, ConsoleFixtureListItem[]> = {
137
+ prs: [
138
+ pullRequestItem(
139
+ 867,
140
+ 'Serve the committed console UI bundle from serveConsole',
141
+ 'PRS00867',
142
+ 'TDPM Console port',
143
+ '2026-06-17T23:41:08.000Z',
144
+ ),
145
+ ],
146
+ triage: [
147
+ issueItem(
148
+ 778,
149
+ 'Add Sonnet to Opus weekly-limit fallback routing per token',
150
+ 'TRI00778',
151
+ 'regular / workflow improvement',
152
+ '2026-06-12T23:01:55.000Z',
153
+ ),
154
+ issueItem(
155
+ 692,
156
+ 'Publish the generated documentation site to GitHub Pages',
157
+ 'TRI00692',
158
+ 'Publish product documentation site',
159
+ '2026-06-10T11:42:00.000Z',
160
+ ),
161
+ ],
162
+ unread: [
163
+ issueItem(
164
+ 845,
165
+ 'Scaffold React console UI under entry-points with build bundling',
166
+ 'UNR00845',
167
+ 'TDPM Console port',
168
+ '2026-06-16T22:01:55.000Z',
169
+ ),
170
+ issueItem(
171
+ 853,
172
+ 'Add server-side console API handlers for read and operation endpoints',
173
+ 'UNR00853',
174
+ 'TDPM Console port',
175
+ '2026-06-17T05:48:09.000Z',
176
+ ),
177
+ ],
178
+ 'failed-preparation': [],
179
+ 'todo-by-human': [
180
+ issueItem(
181
+ 869,
182
+ 'Auto-advance to the next non-empty console tab when one empties',
183
+ 'TODO00869',
184
+ 'TDPM Console port',
185
+ '2026-06-18T00:14:51.000Z',
186
+ ),
187
+ ],
188
+ };
189
+
190
+ const writeFixtureData = (consoleDataOutputDir: string): void => {
191
+ for (const [tab, items] of Object.entries(CONSOLE_E2E_TAB_ITEMS)) {
192
+ const tabDir = path.join(consoleDataOutputDir, CONSOLE_E2E_PJCODE, tab);
193
+ fs.mkdirSync(tabDir, { recursive: true });
194
+ fs.writeFileSync(
195
+ path.join(tabDir, 'list.json'),
196
+ JSON.stringify(buildSnapshot(items)),
197
+ );
198
+ }
199
+ };
200
+
201
+ const buildE2eProject = (): Project => ({
202
+ id: 'PVT_console_e2e',
203
+ url: `https://github.com/orgs/HiromiShikata/projects/1`,
204
+ databaseId: 1,
205
+ name: 'TDPM',
206
+ status: {
207
+ name: 'Status',
208
+ fieldId: 'PVTSSF_status',
209
+ statuses: STATUS_OPTIONS.map((option) => ({
210
+ id: option.id,
211
+ name: option.name,
212
+ color: option.color as Project['status']['statuses'][number]['color'],
213
+ description: '',
214
+ })),
215
+ },
216
+ nextActionDate: { name: 'Next Action Date', fieldId: 'PVTF_nad' },
217
+ nextActionHour: { name: 'Next Action Hour', fieldId: 'PVTF_nah' },
218
+ story: {
219
+ name: 'Story',
220
+ fieldId: 'PVTSSF_story',
221
+ databaseId: 2,
222
+ stories: STORY_OPTIONS.map((option) => ({
223
+ id: option.id,
224
+ name: option.name,
225
+ color: option.color as Project['status']['statuses'][number]['color'],
226
+ description: '',
227
+ })),
228
+ workflowManagementStory: { id: 'wms_1', name: 'regular / workflow' },
229
+ },
230
+ remainingEstimationMinutes: null,
231
+ dependedIssueUrlSeparatedByComma: null,
232
+ completionDate50PercentConfidence: null,
233
+ });
234
+
235
+ const buildIssueForUrl = (url: string): Issue => ({
236
+ nameWithOwner: REPO_NAME_WITH_OWNER,
237
+ number: 0,
238
+ title: 'Console E2E fixture issue',
239
+ state: 'OPEN',
240
+ status: null,
241
+ story: null,
242
+ nextActionDate: null,
243
+ nextActionHour: null,
244
+ estimationMinutes: null,
245
+ dependedIssueUrls: [],
246
+ completionDate50PercentConfidence: null,
247
+ url,
248
+ assignees: [],
249
+ labels: [],
250
+ org: 'HiromiShikata',
251
+ repo: 'npm-cli-github-issue-tower-defence-management',
252
+ body: 'Console E2E fixture issue body.',
253
+ itemId: '',
254
+ isPr: url.includes('/pull/'),
255
+ isInProgress: false,
256
+ isClosed: false,
257
+ createdAt: new Date('2026-06-18T00:00:00.000Z'),
258
+ author: 'HiromiShikata',
259
+ });
260
+
261
+ const notImplemented = (method: string): never => {
262
+ throw new Error(`console E2E stub does not implement ${method}`);
263
+ };
264
+
265
+ const createStubIssueRepository = (): IssueRepository => ({
266
+ getAllIssues: () => notImplemented('getAllIssues'),
267
+ getIssueByUrl: async (url: string): Promise<Issue | null> =>
268
+ buildIssueForUrl(url),
269
+ createNewIssue: () => notImplemented('createNewIssue'),
270
+ searchIssue: () => notImplemented('searchIssue'),
271
+ updateIssue: () => notImplemented('updateIssue'),
272
+ updateNextActionDate: async (): Promise<void> => undefined,
273
+ updateNextActionHour: () => notImplemented('updateNextActionHour'),
274
+ updateProjectTextField: () => notImplemented('updateProjectTextField'),
275
+ updateStory: async (): Promise<void> => undefined,
276
+ updateStatus: async (): Promise<void> => undefined,
277
+ clearProjectField: () => notImplemented('clearProjectField'),
278
+ createComment: () => notImplemented('createComment'),
279
+ updateLabels: () => notImplemented('updateLabels'),
280
+ removeLabel: () => notImplemented('removeLabel'),
281
+ updateAssigneeList: () => notImplemented('updateAssigneeList'),
282
+ get: async (issueUrl: string): Promise<Issue | null> =>
283
+ buildIssueForUrl(issueUrl),
284
+ update: () => notImplemented('update'),
285
+ findRelatedOpenPRs: async (): Promise<RelatedPullRequest[]> => [],
286
+ getOpenPullRequest: async (): Promise<RelatedPullRequest | null> => null,
287
+ getPullRequestChangedFilePaths: async (): Promise<string[]> => [],
288
+ approvePullRequest: async (): Promise<void> => undefined,
289
+ requestChangesWithInlineComment: async (): Promise<void> => undefined,
290
+ closePullRequest: async (): Promise<void> => undefined,
291
+ closeIssueByUrl: async (): Promise<void> => undefined,
292
+ deletePullRequestBranch: () => notImplemented('deletePullRequestBranch'),
293
+ createCommentByUrl: async (): Promise<void> => undefined,
294
+ getAllOpened: () => notImplemented('getAllOpened'),
295
+ getStoryObjectMap: () => notImplemented('getStoryObjectMap'),
296
+ addIssueToProject: () => notImplemented('addIssueToProject'),
297
+ setDependedIssueUrl: () => notImplemented('setDependedIssueUrl'),
298
+ getIssueOrPullRequestBody: async (): Promise<string> =>
299
+ '## Console E2E fixture\n\nThis body is served by the isolated E2E stub.',
300
+ getIssueOrPullRequestComments: async (): Promise<IssueComment[]> => [],
301
+ getPullRequestDetail: async (): Promise<PullRequestDetail | null> => null,
302
+ getPullRequestCommits: async (): Promise<PullRequestCommit[]> => [],
303
+ getIssueOrPullRequestState: async (
304
+ url: string,
305
+ ): Promise<{ state: string; merged: boolean; isPullRequest: boolean }> => ({
306
+ state: 'open',
307
+ merged: false,
308
+ isPullRequest: url.includes('/pull/'),
309
+ }),
310
+ getPullRequestSummary: async (): Promise<{
311
+ title: string;
312
+ body: string;
313
+ additions: number;
314
+ deletions: number;
315
+ changedFiles: number;
316
+ } | null> => null,
317
+ });
318
+
319
+ export type ConsoleE2eHarness = {
320
+ baseUrl: string;
321
+ appUrl: string;
322
+ consoleDataOutputDir: string;
323
+ stop: () => Promise<void>;
324
+ };
325
+
326
+ export const startConsoleE2eHarness = async (): Promise<ConsoleE2eHarness> => {
327
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'console-e2e-'));
328
+ const consoleDataOutputDir = path.join(tmpRoot, 'data');
329
+ writeFixtureData(consoleDataOutputDir);
330
+
331
+ const uiDistDir = path.resolve(__dirname, '..', '..', 'ui-dist');
332
+
333
+ const project = buildE2eProject();
334
+ const resolveProject = async (
335
+ pjcode: string,
336
+ ): Promise<ConsoleProjectBinding | null> =>
337
+ pjcode === CONSOLE_E2E_PJCODE ? { pjcode, project } : null;
338
+
339
+ const server = await startConsoleServer({
340
+ accessToken: CONSOLE_E2E_TOKEN,
341
+ uiDistDir,
342
+ consoleDataOutputDir,
343
+ issueRepository: createStubIssueRepository(),
344
+ resolveProject,
345
+ issueTitleStateCache: new IssueTitleStateCache(),
346
+ inTmuxDataDir: null,
347
+ dashboardDir: null,
348
+ port: 0,
349
+ });
350
+
351
+ const address = server.address();
352
+ if (address === null || typeof address === 'string') {
353
+ await closeServer(server);
354
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
355
+ throw new Error('console E2E server is not listening on a TCP port');
356
+ }
357
+ const baseUrl = `http://127.0.0.1:${address.port}`;
358
+ const appUrl = `${baseUrl}/projects/${CONSOLE_E2E_PJCODE}?k=${CONSOLE_E2E_TOKEN}`;
359
+
360
+ return {
361
+ baseUrl,
362
+ appUrl,
363
+ consoleDataOutputDir,
364
+ stop: async (): Promise<void> => {
365
+ await closeServer(server);
366
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
367
+ },
368
+ };
369
+ };
370
+
371
+ const closeServer = (server: http.Server): Promise<void> =>
372
+ new Promise((resolve, reject) => {
373
+ server.close((error) => {
374
+ if (error) {
375
+ reject(error);
376
+ return;
377
+ }
378
+ resolve();
379
+ });
380
+ });
@@ -0,0 +1,30 @@
1
+ import path from 'node:path';
2
+ import { defineConfig, devices } from '@playwright/test';
3
+
4
+ export default defineConfig({
5
+ testDir: __dirname,
6
+ testMatch: '**/*.spec.ts',
7
+ fullyParallel: false,
8
+ forbidOnly: !!process.env.CI,
9
+ retries: 0,
10
+ workers: 1,
11
+ reporter: [
12
+ ['list'],
13
+ ['html', { open: 'never', outputFolder: path.join(__dirname, 'report') }],
14
+ ],
15
+ outputDir: path.join(__dirname, 'test-results'),
16
+ timeout: 60_000,
17
+ expect: { timeout: 3_000 },
18
+ use: {
19
+ actionTimeout: 3_000,
20
+ navigationTimeout: 3_000,
21
+ trace: 'on-first-retry',
22
+ screenshot: 'only-on-failure',
23
+ },
24
+ projects: [
25
+ {
26
+ name: 'chromium',
27
+ use: { ...devices['Desktop Chrome'] },
28
+ },
29
+ ],
30
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "CommonJS",
6
+ "moduleResolution": "Node",
7
+ "ignoreDeprecations": "6.0",
8
+ "esModuleInterop": true,
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "noUnusedLocals": true,
14
+ "noUnusedParameters": true,
15
+ "types": ["node"]
16
+ },
17
+ "include": ["."]
18
+ }
@@ -59,6 +59,20 @@ export const ZeroCountActiveTabStaysVisible: Story = {
59
59
  },
60
60
  };
61
61
 
62
+ export const AfterAutoAdvanceToNextTab: Story = {
63
+ args: {
64
+ activeTab: 'unread',
65
+ counts: {
66
+ prs: 0,
67
+ triage: 0,
68
+ unread: 7,
69
+ 'failed-preparation': 2,
70
+ 'todo-by-human': 4,
71
+ },
72
+ onSelectTab: () => {},
73
+ },
74
+ };
75
+
62
76
  export const Interactive: Story = {
63
77
  render: (args) => {
64
78
  const [activeTab, setActiveTab] = useState<ConsoleTabName>('prs');
@@ -0,0 +1,48 @@
1
+ import { findNextNonEmptyTabToRight } from './tabAdvance';
2
+ import type { ConsoleTabName } from './types';
3
+
4
+ const counts = (
5
+ overrides: Partial<Record<ConsoleTabName, number>>,
6
+ ): Record<ConsoleTabName, number> => ({
7
+ prs: 0,
8
+ triage: 0,
9
+ unread: 0,
10
+ 'failed-preparation': 0,
11
+ 'todo-by-human': 0,
12
+ ...overrides,
13
+ });
14
+
15
+ describe('findNextNonEmptyTabToRight', () => {
16
+ it('returns the first non-empty tab to the right of the active tab', () => {
17
+ expect(findNextNonEmptyTabToRight('prs', counts({ unread: 7 }))).toBe(
18
+ 'unread',
19
+ );
20
+ });
21
+
22
+ it('skips empty tabs and returns the next non-empty tab further right', () => {
23
+ expect(
24
+ findNextNonEmptyTabToRight(
25
+ 'prs',
26
+ counts({ triage: 0, unread: 0, 'todo-by-human': 4 }),
27
+ ),
28
+ ).toBe('todo-by-human');
29
+ });
30
+
31
+ it('returns the immediately adjacent tab when it is non-empty', () => {
32
+ expect(
33
+ findNextNonEmptyTabToRight('prs', counts({ triage: 12, unread: 7 })),
34
+ ).toBe('triage');
35
+ });
36
+
37
+ it('returns null when no tab to the right has any items', () => {
38
+ expect(
39
+ findNextNonEmptyTabToRight('unread', counts({ prs: 35 })),
40
+ ).toBeNull();
41
+ });
42
+
43
+ it('returns null when the active tab is the last tab', () => {
44
+ expect(
45
+ findNextNonEmptyTabToRight('todo-by-human', counts({ prs: 35 })),
46
+ ).toBeNull();
47
+ });
48
+ });
@@ -0,0 +1,19 @@
1
+ import type { ConsoleTabName } from './types';
2
+ import { CONSOLE_TABS } from './types';
3
+
4
+ export const findNextNonEmptyTabToRight = (
5
+ activeTab: ConsoleTabName,
6
+ counts: Record<ConsoleTabName, number>,
7
+ ): ConsoleTabName | null => {
8
+ const activeIndex = CONSOLE_TABS.findIndex((tab) => tab.name === activeTab);
9
+ if (activeIndex === -1) {
10
+ return null;
11
+ }
12
+ for (let index = activeIndex + 1; index < CONSOLE_TABS.length; index += 1) {
13
+ const candidate = CONSOLE_TABS[index].name;
14
+ if ((counts[candidate] ?? 0) > 0) {
15
+ return candidate;
16
+ }
17
+ }
18
+ return null;
19
+ };
@@ -100,7 +100,7 @@ describe('ConsolePage', () => {
100
100
  it('shows a cancellable toast and only drives the tab to zero after the five second window', async () => {
101
101
  jest.useFakeTimers();
102
102
  try {
103
- const { getByText, findByText } = render(<ConsolePage />);
103
+ const { getByText, findByText, queryByText } = render(<ConsolePage />);
104
104
  await waitFor(() => {
105
105
  expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
106
106
  });
@@ -127,11 +127,7 @@ describe('ConsolePage', () => {
127
127
  });
128
128
 
129
129
  await waitFor(() => {
130
- expect(
131
- getByText('Awaiting Quality Check')
132
- .closest('a')
133
- ?.querySelector('.console-tab-badge')?.textContent,
134
- ).toBe('0');
130
+ expect(queryByText('Awaiting Quality Check')).toBeNull();
135
131
  });
136
132
  } finally {
137
133
  jest.useRealTimers();
@@ -397,3 +393,40 @@ describe('ConsolePage auto-advance', () => {
397
393
  }
398
394
  });
399
395
  });
396
+
397
+ describe('ConsolePage auto-advance tab', () => {
398
+ beforeEach(() => {
399
+ localStorage.clear();
400
+ window.history.replaceState({}, '', '/projects/umino/prs?k=token');
401
+ installFetch();
402
+ });
403
+
404
+ it('auto-advances to the next non-empty tab on the right after the active tab is driven to zero', async () => {
405
+ jest.useFakeTimers();
406
+ try {
407
+ const { getByText, findByText } = render(<ConsolePage />);
408
+ await waitFor(() => {
409
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
410
+ });
411
+
412
+ fireEvent.click(getByText('Add serveConsole subcommand'));
413
+ expect(await findByText('← Back to list')).toBeInTheDocument();
414
+ fireEvent.click(getByText('Approve'));
415
+
416
+ act(() => {
417
+ jest.advanceTimersByTime(5100);
418
+ });
419
+
420
+ await waitFor(() => {
421
+ expect(
422
+ getByText('Notify finished issue preparation'),
423
+ ).toBeInTheDocument();
424
+ });
425
+ expect(
426
+ getByText('Unread').closest('a')?.getAttribute('aria-current'),
427
+ ).toBe('page');
428
+ } finally {
429
+ jest.useRealTimers();
430
+ }
431
+ });
432
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
2
2
  import { ConsoleTabList } from '../components/layout/ConsoleTabList';
3
3
  import { ConsoleItemList } from '../components/list/ConsoleItemList';
4
4
  import { ConsoleUndoToast } from '../components/operations/ConsoleUndoToast';
@@ -27,6 +27,7 @@ import {
27
27
  overlayKeyForItem,
28
28
  } from '../logic/overlay';
29
29
  import type { ConsoleSwipeDirection } from '../logic/swipe';
30
+ import { findNextNonEmptyTabToRight } from '../logic/tabAdvance';
30
31
  import type {
31
32
  ConsoleListItem,
32
33
  ConsoleOverlayStatus,
@@ -52,7 +53,8 @@ export const ConsolePage = () => {
52
53
  const pjcode = useConsolePjcode();
53
54
  const { snapshots, isLoading, error } = useConsoleTabData(pjcode);
54
55
  const navigation = useConsoleNavigation(pjcode);
55
- const { activeTab, selectedItemKey } = navigation;
56
+ const { activeTab, selectedItemKey, openItem, closeItem, selectTab } =
57
+ navigation;
56
58
 
57
59
  const overlayState = useConsoleOverlay(pjcode ?? OVERLAY_NAMESPACE_FALLBACK);
58
60
  const caches = useConsoleCaches();
@@ -109,6 +111,26 @@ export const ConsolePage = () => {
109
111
  );
110
112
  }, [selectedItemKey, activeSnapshot]);
111
113
 
114
+ const activeCount = counts[activeTab];
115
+ const previousActiveTabCountRef = useRef<{
116
+ tab: ConsoleTabName;
117
+ count: number;
118
+ }>({ tab: activeTab, count: activeCount });
119
+ useEffect(() => {
120
+ const previous = previousActiveTabCountRef.current;
121
+ previousActiveTabCountRef.current = { tab: activeTab, count: activeCount };
122
+ if (previous.tab !== activeTab) {
123
+ return;
124
+ }
125
+ if (previous.count > 0 && activeCount === 0) {
126
+ const nextTab = findNextNonEmptyTabToRight(activeTab, counts);
127
+ if (nextTab !== null) {
128
+ selectTab(nextTab);
129
+ closeItem();
130
+ }
131
+ }
132
+ }, [activeTab, activeCount, counts, selectTab, closeItem]);
133
+
112
134
  const overlayStatusForSelected = ((): ConsoleOverlayStatus | null => {
113
135
  if (selectedItem === null) {
114
136
  return null;
@@ -122,8 +144,6 @@ export const ConsolePage = () => {
122
144
  ? resolveItemStory(selectedItem, overlayState.overlay)
123
145
  : null;
124
146
 
125
- const { openItem, closeItem } = navigation;
126
-
127
147
  const advanceToNext = useCallback(
128
148
  (actedKey: string): void => {
129
149
  const nextKey = nextPendingKeyAfter(orderedPendingKeys, actedKey);
@@ -202,6 +222,13 @@ export const ConsolePage = () => {
202
222
  />
203
223
  ) : (
204
224
  <div className="console-detail-screen" ref={detailScreenRef}>
225
+ <button
226
+ type="button"
227
+ className="console-back-button"
228
+ onClick={closeItem}
229
+ >
230
+ ← Back to list
231
+ </button>
205
232
  <ConsoleItemDetailContainer
206
233
  tab={activeTab}
207
234
  item={selectedItem}