github-issue-tower-defence-management 1.91.4 → 1.92.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 (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/bin/adapter/entry-points/cli/index.js +4 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleServer.js +38 -1
  6. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +1 -0
  8. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D04N09aG.js → index-BSNMvjcB.js} +30 -30
  9. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapter/entry-points/cli/index.ts +10 -0
  12. package/src/adapter/entry-points/console/consoleServer.test.ts +232 -0
  13. package/src/adapter/entry-points/console/consoleServer.ts +48 -0
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +4 -4
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +24 -9
  16. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +4 -1
  17. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +16 -8
  18. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +0 -2
  19. package/src/adapter/entry-points/console/ui-dist/assets/index-BJixkRxv.css +1 -0
  20. package/src/adapter/entry-points/console/ui-dist/assets/{index-D04N09aG.js → index-BSNMvjcB.js} +30 -30
  21. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  22. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  23. package/types/adapter/entry-points/console/consoleServer.d.ts +2 -0
  24. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  25. package/bin/adapter/entry-points/console/ui-dist/assets/index-0IuY3q4G.css +0 -1
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleProjectSummary.stories.tsx +0 -29
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleProjectSummary.test.tsx +0 -14
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleProjectSummary.tsx +0 -16
  29. package/src/adapter/entry-points/console/ui-dist/assets/index-0IuY3q4G.css +0 -1
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-D04N09aG.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-0IuY3q4G.css">
7
+ <script type="module" crossorigin src="/assets/index-BSNMvjcB.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BJixkRxv.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.91.4",
3
+ "version": "1.92.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -75,8 +75,12 @@ type ServeConsoleOptions = {
75
75
  configFilePath: string;
76
76
  port?: string;
77
77
  consoleDataOutputDir?: string;
78
+ inTmuxDataDir?: string;
78
79
  };
79
80
 
81
+ const DEFAULT_IN_TMUX_DATA_DIR =
82
+ '/home/hiromi/0_workspaces/workspace1/jsonpub/in-tmux-by-human';
83
+
80
84
  type SelectOauthTokenOptions = {
81
85
  tokenListJsonPath?: string;
82
86
  cacheDir?: string;
@@ -591,6 +595,10 @@ program
591
595
  '--consoleDataOutputDir <path>',
592
596
  'Directory where console data files are written and served from',
593
597
  )
598
+ .option(
599
+ '--inTmuxDataDir <path>',
600
+ `Directory containing the flat in-tmux-by-human static JSON files served at /in-tmux-by-human/*.json (default: ${DEFAULT_IN_TMUX_DATA_DIR})`,
601
+ )
594
602
  .action(async (options: ServeConsoleOptions) => {
595
603
  const config = loadConfigFile(options.configFilePath);
596
604
 
@@ -692,11 +700,13 @@ program
692
700
 
693
701
  const uiDistDir = path.join(__dirname, '..', 'console', 'ui-dist');
694
702
  const consoleDataOutputDir = options.consoleDataOutputDir ?? null;
703
+ const inTmuxDataDir = options.inTmuxDataDir ?? DEFAULT_IN_TMUX_DATA_DIR;
695
704
 
696
705
  await startConsoleServer({
697
706
  accessToken,
698
707
  uiDistDir,
699
708
  consoleDataOutputDir,
709
+ inTmuxDataDir,
700
710
  issueRepository,
701
711
  resolveProject,
702
712
  issueTitleStateCache: new IssueTitleStateCache(),
@@ -11,6 +11,7 @@ import {
11
11
  isTokenValid,
12
12
  isConsoleAppRoute,
13
13
  extractProvidedToken,
14
+ resolveFlatInTmuxFilePath,
14
15
  startConsoleServer,
15
16
  } from './consoleServer';
16
17
  import { IssueTitleStateCache } from './consoleReadApi';
@@ -175,6 +176,7 @@ describe('consoleServer integration', () => {
175
176
  accessToken: testToken,
176
177
  uiDistDir: path.join(tmpDir, 'ui-dist'),
177
178
  consoleDataOutputDir: null,
179
+ inTmuxDataDir: null,
178
180
  port: 0,
179
181
  });
180
182
  const address = server.address();
@@ -190,6 +192,7 @@ describe('consoleServer integration', () => {
190
192
  accessToken: testToken,
191
193
  uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
192
194
  consoleDataOutputDir: null,
195
+ inTmuxDataDir: null,
193
196
  port: 0,
194
197
  });
195
198
  try {
@@ -214,6 +217,7 @@ describe('consoleServer integration', () => {
214
217
  accessToken: testToken,
215
218
  uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
216
219
  consoleDataOutputDir: null,
220
+ inTmuxDataDir: null,
217
221
  port: 0,
218
222
  });
219
223
  try {
@@ -242,6 +246,7 @@ describe('consoleServer integration', () => {
242
246
  accessToken: testToken,
243
247
  uiDistDir,
244
248
  consoleDataOutputDir: null,
249
+ inTmuxDataDir: null,
245
250
  port: 0,
246
251
  });
247
252
  try {
@@ -272,6 +277,7 @@ describe('consoleServer integration', () => {
272
277
  accessToken: testToken,
273
278
  uiDistDir,
274
279
  consoleDataOutputDir: null,
280
+ inTmuxDataDir: null,
275
281
  port: 0,
276
282
  });
277
283
  try {
@@ -299,6 +305,7 @@ describe('consoleServer integration', () => {
299
305
  accessToken: testToken,
300
306
  uiDistDir: path.join(tmpDir, 'missing-ui-dist'),
301
307
  consoleDataOutputDir: null,
308
+ inTmuxDataDir: null,
302
309
  port: 0,
303
310
  });
304
311
  try {
@@ -320,6 +327,7 @@ describe('consoleServer integration', () => {
320
327
  accessToken: testToken,
321
328
  uiDistDir,
322
329
  consoleDataOutputDir: null,
330
+ inTmuxDataDir: null,
323
331
  port: 0,
324
332
  });
325
333
  try {
@@ -344,6 +352,7 @@ describe('consoleServer integration', () => {
344
352
  accessToken: testToken,
345
353
  uiDistDir: path.join(tmpDir, 'ui-dist'),
346
354
  consoleDataOutputDir: null,
355
+ inTmuxDataDir: null,
347
356
  port: 0,
348
357
  });
349
358
  try {
@@ -480,6 +489,7 @@ describe('consoleServer new routes integration', () => {
480
489
  accessToken: testToken,
481
490
  uiDistDir: path.join(tmpDir, 'ui-dist'),
482
491
  consoleDataOutputDir: dataDir,
492
+ inTmuxDataDir: null,
483
493
  port: 0,
484
494
  });
485
495
  try {
@@ -515,6 +525,7 @@ describe('consoleServer new routes integration', () => {
515
525
  accessToken: testToken,
516
526
  uiDistDir: path.join(tmpDir, 'ui-dist'),
517
527
  consoleDataOutputDir: null,
528
+ inTmuxDataDir: null,
518
529
  issueRepository,
519
530
  issueTitleStateCache: new IssueTitleStateCache(),
520
531
  port: 0,
@@ -547,6 +558,7 @@ describe('consoleServer new routes integration', () => {
547
558
  accessToken: testToken,
548
559
  uiDistDir: path.join(tmpDir, 'ui-dist'),
549
560
  consoleDataOutputDir: null,
561
+ inTmuxDataDir: null,
550
562
  issueRepository,
551
563
  resolveProject: async (pjcode) =>
552
564
  pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
@@ -594,6 +606,7 @@ describe('consoleServer new routes integration', () => {
594
606
  accessToken: testToken,
595
607
  uiDistDir: path.join(tmpDir, 'ui-dist'),
596
608
  consoleDataOutputDir: dataDir,
609
+ inTmuxDataDir: null,
597
610
  issueRepository,
598
611
  resolveProject: async (pjcode) =>
599
612
  pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
@@ -631,6 +644,7 @@ describe('consoleServer new routes integration', () => {
631
644
  accessToken: testToken,
632
645
  uiDistDir: path.join(tmpDir, 'ui-dist'),
633
646
  consoleDataOutputDir: null,
647
+ inTmuxDataDir: null,
634
648
  issueRepository,
635
649
  resolveProject: async (pjcode) =>
636
650
  pjcode === 'umino' ? { pjcode, project: buildProject() } : null,
@@ -680,6 +694,7 @@ describe('consoleServer new routes integration', () => {
680
694
  accessToken: testToken,
681
695
  uiDistDir: path.join(tmpDir, 'ui-dist'),
682
696
  consoleDataOutputDir: null,
697
+ inTmuxDataDir: null,
683
698
  port: 0,
684
699
  });
685
700
  try {
@@ -695,3 +710,220 @@ describe('consoleServer new routes integration', () => {
695
710
  }
696
711
  });
697
712
  });
713
+
714
+ describe('resolveFlatInTmuxFilePath', () => {
715
+ const baseDir = path.join(os.tmpdir(), 'in-tmux-data');
716
+
717
+ it('resolves a flat .json file under the in-tmux data dir', () => {
718
+ const resolved = resolveFlatInTmuxFilePath(
719
+ baseDir,
720
+ '/in-tmux-by-human/index.v4.json',
721
+ );
722
+ expect(resolved).toBe(path.join(path.resolve(baseDir), 'index.v4.json'));
723
+ });
724
+
725
+ it('returns null for paths outside the flat in-tmux prefix', () => {
726
+ expect(resolveFlatInTmuxFilePath(baseDir, '/index.v4.json')).toBeNull();
727
+ expect(
728
+ resolveFlatInTmuxFilePath(baseDir, '/projects/umino/in-tmux-by-human/x'),
729
+ ).toBeNull();
730
+ });
731
+
732
+ it('returns null for non-json or nested file names', () => {
733
+ expect(
734
+ resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/index.txt'),
735
+ ).toBeNull();
736
+ expect(
737
+ resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/sub/index.json'),
738
+ ).toBeNull();
739
+ expect(resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/')).toBeNull();
740
+ });
741
+
742
+ it('returns null for path traversal attempts', () => {
743
+ expect(
744
+ resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/../secret.json'),
745
+ ).toBeNull();
746
+ expect(
747
+ resolveFlatInTmuxFilePath(baseDir, '/in-tmux-by-human/..%2fsecret.json'),
748
+ ).toBeNull();
749
+ });
750
+ });
751
+
752
+ describe('consoleServer flat in-tmux-by-human route integration', () => {
753
+ const testToken = 'integration-test-token-value';
754
+
755
+ const requestServer = (
756
+ server: http.Server,
757
+ requestPath: string,
758
+ headers: http.OutgoingHttpHeaders = {},
759
+ ): Promise<{
760
+ statusCode: number;
761
+ body: string;
762
+ cacheControl: string | undefined;
763
+ contentType: string | undefined;
764
+ }> => {
765
+ const address = server.address();
766
+ if (address === null || typeof address === 'string') {
767
+ throw new Error('server is not listening on a TCP port');
768
+ }
769
+ const port = address.port;
770
+ return new Promise((resolve, reject) => {
771
+ const httpRequest = http.request(
772
+ { host: '127.0.0.1', port, path: requestPath, headers },
773
+ (response) => {
774
+ const chunks: Uint8Array[] = [];
775
+ response.on('data', (chunk: Uint8Array) => chunks.push(chunk));
776
+ response.on('end', () => {
777
+ resolve({
778
+ statusCode: response.statusCode ?? 0,
779
+ body: Buffer.concat(chunks).toString('utf-8'),
780
+ cacheControl: response.headers['cache-control'],
781
+ contentType: response.headers['content-type'],
782
+ });
783
+ });
784
+ },
785
+ );
786
+ httpRequest.on('error', reject);
787
+ httpRequest.end();
788
+ });
789
+ };
790
+
791
+ const closeServer = (server: http.Server): Promise<void> =>
792
+ new Promise((resolve, reject) => {
793
+ server.close((error) => {
794
+ if (error) {
795
+ reject(error);
796
+ return;
797
+ }
798
+ resolve();
799
+ });
800
+ });
801
+
802
+ const indexV4Raw =
803
+ '{"version":4,"projects":[{"name":"umino","path":"/in-tmux-by-human/umino.v4.json?k=secret"}]}\n';
804
+ const indexV3Raw = '{"version":3,"projects":["umino"]}\n';
805
+
806
+ const startWithFixture = async (): Promise<{
807
+ server: http.Server;
808
+ tmpDir: string;
809
+ inTmuxDataDir: string;
810
+ }> => {
811
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
812
+ const inTmuxDataDir = path.join(tmpDir, 'in-tmux-by-human');
813
+ fs.mkdirSync(inTmuxDataDir, { recursive: true });
814
+ fs.writeFileSync(path.join(inTmuxDataDir, 'index.v4.json'), indexV4Raw);
815
+ fs.writeFileSync(path.join(inTmuxDataDir, 'index.v3.json'), indexV3Raw);
816
+ fs.writeFileSync(path.join(tmpDir, 'secret.json'), '{"secret":true}');
817
+ const server = await startConsoleServer({
818
+ accessToken: testToken,
819
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
820
+ consoleDataOutputDir: null,
821
+ inTmuxDataDir,
822
+ port: 0,
823
+ });
824
+ return { server, tmpDir, inTmuxDataDir };
825
+ };
826
+
827
+ it('serves the flat index.v4.json byte-for-byte with a valid token', async () => {
828
+ const { server, tmpDir } = await startWithFixture();
829
+ try {
830
+ const response = await requestServer(
831
+ server,
832
+ `/in-tmux-by-human/index.v4.json?k=${testToken}`,
833
+ );
834
+ expect(response.statusCode).toBe(200);
835
+ expect(response.body).toBe(indexV4Raw);
836
+ expect(response.contentType).toContain('application/json');
837
+ expect(response.cacheControl).toBe('no-store');
838
+ } finally {
839
+ await closeServer(server);
840
+ fs.rmSync(tmpDir, { recursive: true, force: true });
841
+ }
842
+ });
843
+
844
+ it('rejects the flat route without a token', async () => {
845
+ const { server, tmpDir } = await startWithFixture();
846
+ try {
847
+ const response = await requestServer(
848
+ server,
849
+ '/in-tmux-by-human/index.v4.json',
850
+ );
851
+ expect(response.statusCode).toBe(401);
852
+ } finally {
853
+ await closeServer(server);
854
+ fs.rmSync(tmpDir, { recursive: true, force: true });
855
+ }
856
+ });
857
+
858
+ it('serves a v3 file for backward compatibility', async () => {
859
+ const { server, tmpDir } = await startWithFixture();
860
+ try {
861
+ const response = await requestServer(
862
+ server,
863
+ `/in-tmux-by-human/index.v3.json?k=${testToken}`,
864
+ );
865
+ expect(response.statusCode).toBe(200);
866
+ expect(response.body).toBe(indexV3Raw);
867
+ } finally {
868
+ await closeServer(server);
869
+ fs.rmSync(tmpDir, { recursive: true, force: true });
870
+ }
871
+ });
872
+
873
+ it('rejects path traversal attempts with 404 and does not disclose files', async () => {
874
+ const { server, tmpDir } = await startWithFixture();
875
+ try {
876
+ const traversal = await requestServer(
877
+ server,
878
+ `/in-tmux-by-human/../secret.json?k=${testToken}`,
879
+ );
880
+ expect(traversal.statusCode).toBe(404);
881
+ expect(traversal.body).not.toContain('secret');
882
+
883
+ const encodedTraversal = await requestServer(
884
+ server,
885
+ `/in-tmux-by-human/..%2fsecret.json?k=${testToken}`,
886
+ );
887
+ expect(encodedTraversal.statusCode).toBe(404);
888
+ expect(encodedTraversal.body).not.toContain('secret');
889
+ } finally {
890
+ await closeServer(server);
891
+ fs.rmSync(tmpDir, { recursive: true, force: true });
892
+ }
893
+ });
894
+
895
+ it('returns 404 for a non-existent flat file', async () => {
896
+ const { server, tmpDir } = await startWithFixture();
897
+ try {
898
+ const response = await requestServer(
899
+ server,
900
+ `/in-tmux-by-human/missing.v4.json?k=${testToken}`,
901
+ );
902
+ expect(response.statusCode).toBe(404);
903
+ } finally {
904
+ await closeServer(server);
905
+ fs.rmSync(tmpDir, { recursive: true, force: true });
906
+ }
907
+ });
908
+
909
+ it('returns 404 for the flat route when inTmuxDataDir is null', async () => {
910
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'console-server-'));
911
+ const server = await startConsoleServer({
912
+ accessToken: testToken,
913
+ uiDistDir: path.join(tmpDir, 'ui-dist'),
914
+ consoleDataOutputDir: null,
915
+ inTmuxDataDir: null,
916
+ port: 0,
917
+ });
918
+ try {
919
+ const response = await requestServer(
920
+ server,
921
+ `/in-tmux-by-human/index.v4.json?k=${testToken}`,
922
+ );
923
+ expect(response.statusCode).toBe(404);
924
+ } finally {
925
+ await closeServer(server);
926
+ fs.rmSync(tmpDir, { recursive: true, force: true });
927
+ }
928
+ });
929
+ });
@@ -154,11 +154,36 @@ export type ConsoleServerOptions = {
154
154
  accessToken: string;
155
155
  uiDistDir: string;
156
156
  consoleDataOutputDir: string | null;
157
+ inTmuxDataDir: string | null;
157
158
  issueRepository?: IssueRepository | null;
158
159
  resolveProject?: ConsoleProjectResolver | null;
159
160
  issueTitleStateCache?: IssueTitleStateCache | null;
160
161
  };
161
162
 
163
+ const FLAT_IN_TMUX_PREFIX = '/in-tmux-by-human/';
164
+
165
+ const FLAT_IN_TMUX_FILE = /^[A-Za-z0-9._-]+\.json$/;
166
+
167
+ export const resolveFlatInTmuxFilePath = (
168
+ inTmuxDataDir: string,
169
+ requestPath: string,
170
+ ): string | null => {
171
+ if (!requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
172
+ return null;
173
+ }
174
+ const fileName = requestPath.slice(FLAT_IN_TMUX_PREFIX.length);
175
+ if (fileName.length === 0 || !FLAT_IN_TMUX_FILE.test(fileName)) {
176
+ return null;
177
+ }
178
+ const candidate = path.join(inTmuxDataDir, fileName);
179
+ const resolvedRoot = path.resolve(inTmuxDataDir);
180
+ const resolvedCandidate = path.resolve(candidate);
181
+ if (!resolvedCandidate.startsWith(resolvedRoot + path.sep)) {
182
+ return null;
183
+ }
184
+ return resolvedCandidate;
185
+ };
186
+
162
187
  const sendNotFound = (response: http.ServerResponse): void => {
163
188
  response.writeHead(404, {
164
189
  'Content-Type': 'text/plain; charset=utf-8',
@@ -361,6 +386,29 @@ const handleTokenedRequest = async (
361
386
  }
362
387
 
363
388
  if (method === 'GET') {
389
+ if (requestPath.startsWith(FLAT_IN_TMUX_PREFIX)) {
390
+ if (options.inTmuxDataDir === null) {
391
+ sendNotFound(response);
392
+ return;
393
+ }
394
+ const flatFilePath = resolveFlatInTmuxFilePath(
395
+ options.inTmuxDataDir,
396
+ requestPath,
397
+ );
398
+ const flatContent =
399
+ flatFilePath === null ? null : readStaticFile(flatFilePath);
400
+ if (flatContent === null) {
401
+ sendNotFound(response);
402
+ return;
403
+ }
404
+ response.writeHead(200, {
405
+ 'Content-Type': 'application/json; charset=utf-8',
406
+ 'Cache-Control': 'no-store',
407
+ });
408
+ response.end(flatContent);
409
+ return;
410
+ }
411
+
364
412
  const dataRoute = parseConsoleDataRoute(requestPath);
365
413
  if (dataRoute !== null && options.consoleDataOutputDir !== null) {
366
414
  const dataResponse = buildConsoleDataResponse(
@@ -33,7 +33,7 @@ export const AllTabsWithCounts: Story = {
33
33
  },
34
34
  };
35
35
 
36
- export const ZeroCountTabsStillShown: Story = {
36
+ export const ZeroCountTabsHidden: Story = {
37
37
  args: {
38
38
  activeTab: 'prs',
39
39
  counts: {
@@ -46,15 +46,15 @@ export const ZeroCountTabsStillShown: Story = {
46
46
  },
47
47
  };
48
48
 
49
- export const ZeroCountActiveTab: Story = {
49
+ export const ZeroCountActiveTabStaysVisible: Story = {
50
50
  args: {
51
51
  activeTab: 'failed-preparation',
52
52
  counts: {
53
53
  prs: 35,
54
- triage: 132,
54
+ triage: 0,
55
55
  unread: 18,
56
56
  'failed-preparation': 0,
57
- 'todo-by-human': 66,
57
+ 'todo-by-human': 0,
58
58
  },
59
59
  },
60
60
  };
@@ -18,26 +18,41 @@ const baseProps = {
18
18
  };
19
19
 
20
20
  describe('ConsoleTabList', () => {
21
- it('shows every tab including zero-count tabs', () => {
21
+ it('hides zero-count tabs while showing non-zero tabs', () => {
22
22
  const { queryByText } = render(
23
23
  <ConsoleTabList {...baseProps} activeTab="prs" counts={counts} />,
24
24
  );
25
25
  expect(queryByText('Awaiting Quality Check')).not.toBeNull();
26
- expect(queryByText('Triage')).not.toBeNull();
27
26
  expect(queryByText('Unread')).not.toBeNull();
28
- expect(queryByText('Failed Preparation')).not.toBeNull();
29
27
  expect(queryByText('Todo by human')).not.toBeNull();
28
+ expect(queryByText('Triage')).toBeNull();
29
+ expect(queryByText('Failed Preparation')).toBeNull();
30
30
  });
31
31
 
32
- it('marks the active tab and renders zero badges with the zero attribute', () => {
33
- const { getByText } = render(
34
- <ConsoleTabList {...baseProps} activeTab="prs" counts={counts} />,
32
+ it('keeps the active tab visible even when its count is zero', () => {
33
+ const { getByText, queryByText } = render(
34
+ <ConsoleTabList
35
+ {...baseProps}
36
+ activeTab="failed-preparation"
37
+ counts={counts}
38
+ />,
35
39
  );
36
- const triageBadge = getByText('Triage')
40
+ const activeBadge = getByText('Failed Preparation')
37
41
  .closest('a')
38
42
  ?.querySelector('.console-tab-badge');
39
- expect(triageBadge).toHaveAttribute('data-zero', 'true');
40
- expect(triageBadge?.textContent).toBe('0');
43
+ expect(activeBadge).toHaveAttribute('data-zero', 'true');
44
+ expect(activeBadge?.textContent).toBe('0');
45
+ expect(getByText('Failed Preparation').closest('a')).toHaveAttribute(
46
+ 'aria-current',
47
+ 'page',
48
+ );
49
+ expect(queryByText('Triage')).toBeNull();
50
+ });
51
+
52
+ it('marks the active tab as the current page', () => {
53
+ const { getByText } = render(
54
+ <ConsoleTabList {...baseProps} activeTab="prs" counts={counts} />,
55
+ );
41
56
  expect(getByText('Awaiting Quality Check').closest('a')).toHaveAttribute(
42
57
  'aria-current',
43
58
  'page',
@@ -29,7 +29,10 @@ export const ConsoleTabList = ({
29
29
  return (
30
30
  <>
31
31
  <nav aria-label="Console tabs" className="console-tabbar">
32
- {CONSOLE_TABS.map((tab) => {
32
+ {CONSOLE_TABS.filter((tab) => {
33
+ const count = counts[tab.name] ?? 0;
34
+ return count > 0 || tab.name === activeTab;
35
+ }).map((tab) => {
33
36
  const count = counts[tab.name] ?? 0;
34
37
  const isActive = tab.name === activeTab;
35
38
  return (
@@ -127,19 +127,27 @@ describe('ConsolePage', () => {
127
127
  ).toBeInTheDocument();
128
128
  });
129
129
 
130
- const prsTabLink = queryByText('Awaiting Quality Check')?.closest('a');
131
- const prsBadge =
132
- prsTabLink?.querySelector('.console-tab-badge')?.textContent ?? '0';
133
- expect(prsBadge).toBe('0');
130
+ expect(queryByText('Awaiting Quality Check')).toBeNull();
134
131
  });
135
132
 
136
- it('shows every tab even when its count is zero', async () => {
133
+ it('hides zero-count tabs but keeps non-zero tabs', async () => {
137
134
  const { getByText, queryByText } = render(<ConsolePage />);
138
135
  await waitFor(() => {
139
136
  expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
140
137
  });
141
- expect(queryByText('Triage')).not.toBeNull();
142
- expect(queryByText('Failed Preparation')).not.toBeNull();
143
- expect(queryByText('Todo by human')).not.toBeNull();
138
+ expect(queryByText('Awaiting Quality Check')).not.toBeNull();
139
+ expect(queryByText('Unread')).not.toBeNull();
140
+ expect(queryByText('Triage')).toBeNull();
141
+ expect(queryByText('Failed Preparation')).toBeNull();
142
+ expect(queryByText('Todo by human')).toBeNull();
143
+ });
144
+
145
+ it('does not render the project header bar above the tab bar', async () => {
146
+ const { getByText, queryByText } = render(<ConsolePage />);
147
+ await waitFor(() => {
148
+ expect(getByText('Add serveConsole subcommand')).toBeInTheDocument();
149
+ });
150
+ expect(queryByText('TDPM Console')).toBeNull();
151
+ expect(queryByText('project: umino')).toBeNull();
144
152
  });
145
153
  });
@@ -1,5 +1,4 @@
1
1
  import { useMemo } from 'react';
2
- import { ConsoleProjectSummary } from '../components/layout/ConsoleProjectSummary';
3
2
  import { ConsoleTabList } from '../components/layout/ConsoleTabList';
4
3
  import { ConsoleItemList } from '../components/list/ConsoleItemList';
5
4
  import { useConsoleCaches } from '../hooks/useConsoleCaches';
@@ -102,7 +101,6 @@ export const ConsolePage = () => {
102
101
 
103
102
  return (
104
103
  <main className="console-app">
105
- <ConsoleProjectSummary pjcode={pjcode} />
106
104
  <ConsoleTabList
107
105
  activeTab={activeTab}
108
106
  counts={counts}
@@ -0,0 +1 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-font-weight:initial;--tw-outline-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-input:#e5e5e5;--color-ring:#0a0a0a;--color-background:#fff;--color-foreground:#0a0a0a;--color-primary:#171717;--color-primary-foreground:#fafafa;--color-secondary:#f5f5f5;--color-secondary-foreground:#171717;--color-accent:#f5f5f5;--color-accent-foreground:#171717}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.relative{position:relative}.static{position:static}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.inline-flex{display:inline-flex}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-input{border-color:var(--color-input)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--color-background)}.bg-primary{background-color:var(--color-primary)}.bg-secondary{background-color:var(--color-secondary)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-foreground{color:var(--color-foreground)}.text-primary-foreground{color:var(--color-primary-foreground)}.text-secondary-foreground{color:var(--color-secondary-foreground)}.lowercase{text-transform:lowercase}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-primary\/90:hover{background-color:#171717e6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--color-primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:#f5f5f5cc}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--color-secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--color-accent-foreground)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--color-ring)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}}body{color:#e6edf3;background-color:#0d1117;margin:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.console-app{flex-direction:column;max-width:920px;margin:0 auto;display:flex}.console-tabbar{background:#161b22;border-bottom:2px solid #30363d;align-items:stretch;gap:0;min-height:42px;padding:0 8px;display:flex}.console-tab{color:#8b949e;white-space:nowrap;cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;align-items:center;gap:6px;margin-bottom:-2px;padding:10px 14px;font-size:13px;font-weight:500;line-height:1.2;text-decoration:none;display:inline-flex}.console-tab:hover{color:#e6edf3}.console-tab[data-active=true]{color:#e6edf3;border-bottom-color:#2f81f7;font-weight:700}.console-tab-badge{text-align:center;color:#e6edf3;background:#484f58;border-radius:20px;min-width:20px;padding:1px 7px;font-size:11px;font-weight:700;line-height:1.5}.console-tab-badge[data-zero=true]{color:#8b949e;background:#30363d}.console-tab-pjname{color:#8b949e;align-self:center;margin-left:auto;padding:0 8px;font-size:11.5px}.console-tab-geninfo{color:#8b949e;align-self:center;padding:0 4px;font-size:11px}.console-tab-count-heading{color:#8b949e;margin:0;padding:12px 18px 0;font-size:13px}.console-list{margin:0;padding:12px 18px 18px;list-style:none}.console-list-group{list-style:none}.console-item-row .console-item-icon{flex:none;margin-top:3px}.console-group-header{background:#0b0f14;border-bottom:1px solid #21262d;justify-content:space-between;align-items:center;padding:6px 12px;display:flex}.console-storytag{align-items:center;gap:8px;font-size:13px;font-weight:700;display:inline-flex}.console-story-dot{border-radius:999px;width:10px;height:10px;display:inline-block}.console-group-count{color:#8b949e;font-size:12px}.console-item-row{color:#e6edf3;text-align:left;cursor:pointer;background:#161b22;border:1px solid #30363d;border-radius:8px;align-items:flex-start;gap:14px;width:100%;margin-bottom:10px;padding:12px 16px;display:flex}.console-item-row:hover{background:#1a2029;border-color:#484f58}.console-item-row[data-active=true]{background:#1a2029;border-color:#4493f8}.console-item-meta{flex:1;min-width:0}.console-item-title{font-size:14.5px;font-weight:600;display:block}.console-item-sub{color:#8b949e;margin-top:3px;font-size:12.5px;display:block}.console-item-pill{color:#8b949e;border:1px solid #30363d;border-radius:20px;margin-right:6px;padding:1px 8px;font-size:11px;display:inline-block}.console-item-createdat{color:#8b949e;cursor:help}.console-list-message{color:#8b949e;padding:16px;font-size:14px}.console-list-empty{text-align:center;color:#8b949e;padding:40px;font-size:14px}.console-list-error,.console-comment-error,.console-files-error,.console-commits-error,.console-detail-body-error{color:#f85149}.console-detail{flex-direction:column;gap:12px;padding:16px;display:flex}.console-detail-title{align-items:center;gap:8px;margin:0;font-size:20px;display:flex}.console-detail-title-text{flex:1}.console-detail-number{color:#8b949e;font-weight:400}.console-detail-closed-label{color:#a371f7;font-size:13px}.console-detail-subbar{align-items:center;gap:12px;font-size:13px;display:flex}.console-detail-link{color:#4493f8}.console-detail-repo{color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-detail-pill,.console-label-chip,.console-detail-status-chip{border:1px solid #30363d;border-radius:999px;padding:2px 8px;font-size:12px;display:inline-block}.console-detail-labels{flex-wrap:wrap;gap:6px;display:flex}.console-detail-createdat{color:#8b949e;font-size:12px}.console-panel{border:1px solid #30363d;border-radius:8px;overflow:hidden}.console-panel-header{background:#161b22;justify-content:space-between;align-items:center;padding:6px 12px;display:flex}.console-panel-toggle{color:#e6edf3;cursor:pointer;background:0 0;border:none;align-items:center;gap:8px;font-size:14px;font-weight:600;display:inline-flex}.console-panel-body{padding:12px}.console-markdown{word-break:break-word;font-size:14px;line-height:1.5}.console-mermaid-error{color:#f85149;font-size:13px}.console-comment{border-top:1px solid #21262d;padding-top:8px}.console-comment-header{color:#8b949e;gap:8px;font-size:12px;display:flex}.console-comment-author{color:#e6edf3;font-weight:600}.console-files,.console-commits{margin:0;padding:0;list-style:none}.console-file,.console-commit{align-items:center;gap:8px;padding:4px 0;font-size:13px;display:flex}.console-file-badge{text-align:center;border:1px solid;border-radius:4px;width:18px;font-size:11px}.console-file-path,.console-commit-message{flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-file-add,.console-pr-add{color:#3fb950}.console-file-del,.console-pr-del{color:#f85149}.console-commit-sha{color:#8b949e;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.console-actionbar{z-index:100;background:#161b22;border-top:2px solid #30363d;padding:10px 16px;position:fixed;bottom:0;left:0;right:0}.console-operation-bar{flex-direction:column;gap:8px;max-width:920px;margin:0 auto;display:flex}.console-op-group{flex-wrap:wrap;gap:8px;display:flex}.console-op-group-review{justify-content:flex-end;align-items:center;gap:10px}.console-op-button{color:#e6edf3;white-space:nowrap;cursor:pointer;background:#21262d;border:1px solid #30363d;border-radius:6px;padding:7px 16px;font-size:13px;font-weight:600}.console-op-button:hover{border-color:#484f58}.console-op-button-approve{color:#fff;background:#238636;border-color:#2ea043}.console-op-button-reject{color:#ffd166;background:#7d5000;border-color:#a06800}.console-op-button-wrong{color:#f85149;background:#3a1518;border-color:#f85149}.console-op-button-unneeded{color:#aab0b8;background:#2a2d31;border-color:#6e7681}.console-op-button-snooze{color:#79c0ff;background:#1c2b4a;border-color:#4493f8}.console-pr-section{border:1px solid #30363d;border-radius:8px;flex-direction:column;gap:10px;padding:12px;display:flex}.console-pr-statbar{color:#8b949e;gap:12px;font-size:12px;display:flex}.console-detail-screen{padding-bottom:140px}.console-panel-open-link{color:#4493f8;font-size:13px;font-weight:400}.console-composer{margin-top:4px}.console-composer-toggle{color:#8b949e;cursor:pointer;background:0 0;border:none;padding:2px 0;font-size:12.5px}.console-composer-toggle:hover{color:#e6edf3}.console-composer-posted{flex-direction:column;gap:8px;margin-top:8px;display:flex}.console-composer-form{margin-top:8px}.console-composer-input{box-sizing:border-box;color:#e6edf3;width:100%;font:inherit;resize:vertical;background:#21262d;border:1px solid #30363d;border-radius:6px;padding:8px;font-size:14px}.console-composer-row{align-items:center;gap:8px;margin-top:6px;display:flex}.console-composer-submit{color:#fff;cursor:pointer;background:#238636;border:1px solid #2ea043;border-radius:6px;padding:7px 16px;font-size:13px;font-weight:600}.console-composer-submit:disabled{opacity:.6;cursor:default}.console-composer-status{color:#8b949e;font-size:12px}.console-composer-error{color:#f85149}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}