github-issue-tower-defence-management 2.7.0 → 2.9.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 (80) hide show
  1. package/.github/workflows/umino-project.yml +0 -8
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +1 -1
  4. package/bin/adapter/entry-points/cli/index.js +3 -1
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js +347 -0
  7. package/bin/adapter/entry-points/console/consoleAirplaneSnapshotApi.js.map +1 -0
  8. package/bin/adapter/entry-points/console/consoleOperationApi.js +12 -0
  9. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  10. package/bin/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +88 -0
  11. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  12. package/bin/adapter/entry-points/console/webServer.js +28 -0
  13. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  14. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +5 -1
  15. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  16. package/bin/adapter/repositories/projectFieldDefinition.js +1 -0
  17. package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -1
  18. package/jest.config.js +1 -0
  19. package/package.json +1 -1
  20. package/src/adapter/entry-points/cli/index.ts +4 -1
  21. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.test.ts +658 -0
  22. package/src/adapter/entry-points/console/consoleAirplaneSnapshotApi.ts +482 -0
  23. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +84 -0
  24. package/src/adapter/entry-points/console/consoleOperationApi.ts +17 -0
  25. package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +6 -0
  26. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +5 -1
  27. package/src/adapter/entry-points/console/ui/jest-globals.cjs +26 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.test.tsx +10 -0
  29. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleFileDiff.tsx +23 -16
  30. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.stories.tsx +56 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.test.tsx +111 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleAirplaneModeButton.tsx +92 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.stories.tsx +15 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.test.tsx +19 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/components/layout/ConsoleTabList.tsx +30 -1
  36. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleErrorToast.stories.tsx +10 -0
  37. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleNextActionDateActions.test.tsx +7 -1
  38. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleNextActionDateActions.tsx +14 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.test.tsx +1 -3
  40. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleUndoToast.tsx +8 -7
  41. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.test.ts +304 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useAirplaneMode.ts +167 -0
  43. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +15 -0
  44. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +6 -1
  45. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.test.ts +77 -0
  46. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleCaches.ts +48 -3
  47. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.test.ts +51 -0
  48. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleFeaturesConfig.ts +39 -0
  49. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.test.ts +215 -0
  50. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleTabData.ts +20 -1
  51. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.test.ts +361 -0
  52. package/src/adapter/entry-points/console/ui/src/features/console/lib/airplaneSnapshot.ts +401 -0
  53. package/src/adapter/entry-points/console/ui/src/features/console/lib/resourceCache.ts +4 -0
  54. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.test.ts +22 -0
  55. package/src/adapter/entry-points/console/ui/src/features/console/logic/actionToast.ts +6 -0
  56. package/src/adapter/entry-points/console/ui/src/features/console/logic/operations.ts +5 -1
  57. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +209 -0
  58. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +27 -2
  59. package/src/adapter/entry-points/console/ui-dist/assets/index-BSa2JTna.js +88 -0
  60. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  61. package/src/adapter/entry-points/console/webServer.test.ts +189 -0
  62. package/src/adapter/entry-points/console/webServer.ts +38 -0
  63. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +63 -8
  64. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +7 -1
  65. package/src/adapter/repositories/projectFieldDefinition.test.ts +1 -0
  66. package/src/adapter/repositories/projectFieldDefinition.ts +1 -0
  67. package/src/domain/entities/Project.ts +1 -0
  68. package/src/domain/usecases/ClearPastNextActionDateHourUseCase.test.ts +1 -0
  69. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  70. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts +90 -0
  71. package/types/adapter/entry-points/console/consoleAirplaneSnapshotApi.d.ts.map +1 -0
  72. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  73. package/types/adapter/entry-points/console/webServer.d.ts +1 -0
  74. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  75. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  76. package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -1
  77. package/types/domain/entities/Project.d.ts +1 -0
  78. package/types/domain/entities/Project.d.ts.map +1 -1
  79. package/bin/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
  80. package/src/adapter/entry-points/console/ui-dist/assets/index-CaxYBHiT.js +0 -86
@@ -0,0 +1,304 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import {
3
+ type AirplaneSnapshot,
4
+ readAirplaneModeFlag,
5
+ writeAirplaneModeFlag,
6
+ } from '../lib/airplaneSnapshot';
7
+ import { useAirplaneMode } from './useAirplaneMode';
8
+
9
+ const makeMinimalSnapshot = (
10
+ overrides: Partial<AirplaneSnapshot> = {},
11
+ ): AirplaneSnapshot => ({
12
+ capturedAt: '2026-01-01T00:00:00Z',
13
+ tabs: {},
14
+ items: {},
15
+ failures: [],
16
+ ...overrides,
17
+ });
18
+
19
+ const encodeSseEvent = (data: unknown): string =>
20
+ `data: ${JSON.stringify(data)}\n\n`;
21
+
22
+ const buildSseBody = (events: unknown[]): ReadableStream<Uint8Array> => {
23
+ const encoder = new TextEncoder();
24
+ const chunks = events.map((e) => encoder.encode(encodeSseEvent(e)));
25
+ return new ReadableStream<Uint8Array>({
26
+ start(controller) {
27
+ for (const chunk of chunks) {
28
+ controller.enqueue(chunk);
29
+ }
30
+ controller.close();
31
+ },
32
+ });
33
+ };
34
+
35
+ const mockFetchSse = (events: unknown[]): void => {
36
+ global.fetch = jest.fn().mockResolvedValue({
37
+ ok: true,
38
+ status: 200,
39
+ body: buildSseBody(events),
40
+ });
41
+ };
42
+
43
+ const mockCacheApi = (): {
44
+ stored: AirplaneSnapshot | null;
45
+ cacheStore: Map<string, string>;
46
+ } => {
47
+ let stored: AirplaneSnapshot | null = null;
48
+ const cacheStore = new Map<string, string>();
49
+
50
+ const mockCache = {
51
+ put: jest.fn(async (_key: string, response: Response) => {
52
+ const text = await response.text();
53
+ cacheStore.set('snapshot', text);
54
+ stored = JSON.parse(text) as AirplaneSnapshot;
55
+ }),
56
+ match: jest.fn(async () => {
57
+ if (!cacheStore.has('snapshot')) {
58
+ return undefined;
59
+ }
60
+ return new Response(cacheStore.get('snapshot'), {
61
+ headers: { 'Content-Type': 'application/json' },
62
+ });
63
+ }),
64
+ delete: jest.fn(),
65
+ };
66
+
67
+ Object.defineProperty(global, 'caches', {
68
+ writable: true,
69
+ value: {
70
+ open: jest.fn().mockResolvedValue(mockCache),
71
+ delete: jest.fn().mockResolvedValue(true),
72
+ },
73
+ });
74
+
75
+ return { stored: stored, cacheStore };
76
+ };
77
+
78
+ describe('useAirplaneMode', () => {
79
+ beforeEach(() => {
80
+ localStorage.clear();
81
+ mockCacheApi();
82
+ });
83
+
84
+ it('starts in off status', () => {
85
+ const { result } = renderHook(() => useAirplaneMode());
86
+ expect(result.current.status).toBe('off');
87
+ expect(result.current.snapshot).toBeNull();
88
+ expect(result.current.progress).toBeNull();
89
+ expect(result.current.failures).toEqual([]);
90
+ });
91
+
92
+ it('does not report on while a fetch is outstanding', async () => {
93
+ let resolveStream!: () => void;
94
+ const streamBlocked = new Promise<void>((res) => {
95
+ resolveStream = res;
96
+ });
97
+
98
+ const encoder = new TextEncoder();
99
+ global.fetch = jest.fn().mockResolvedValue({
100
+ ok: true,
101
+ status: 200,
102
+ body: new ReadableStream<Uint8Array>({
103
+ start(controller) {
104
+ controller.enqueue(
105
+ encoder.encode(
106
+ encodeSseEvent({ type: 'progress', fetched: 0, total: 5 }),
107
+ ),
108
+ );
109
+ streamBlocked
110
+ .then(() => {
111
+ controller.close();
112
+ })
113
+ .catch(() => {});
114
+ },
115
+ }),
116
+ });
117
+
118
+ const { result } = renderHook(() => useAirplaneMode());
119
+
120
+ act(() => {
121
+ result.current.startSync();
122
+ });
123
+
124
+ await waitFor(() => {
125
+ expect(result.current.status).toBe('syncing');
126
+ });
127
+
128
+ expect(result.current.status).not.toBe('on');
129
+
130
+ resolveStream();
131
+ });
132
+
133
+ it('transitions to on only after the done event with no failures', async () => {
134
+ const snapshot = makeMinimalSnapshot();
135
+ mockFetchSse([
136
+ { type: 'progress', fetched: 0, total: 1 },
137
+ { type: 'progress', fetched: 1, total: 1 },
138
+ { type: 'done', snapshot },
139
+ ]);
140
+
141
+ const { result } = renderHook(() => useAirplaneMode());
142
+
143
+ act(() => {
144
+ result.current.startSync();
145
+ });
146
+
147
+ await waitFor(() => {
148
+ expect(result.current.status).toBe('on');
149
+ });
150
+
151
+ expect(result.current.snapshot).not.toBeNull();
152
+ expect(result.current.snapshot?.capturedAt).toBe('2026-01-01T00:00:00Z');
153
+ expect(result.current.failures).toEqual([]);
154
+ });
155
+
156
+ it('sets status to error and does not turn on when the done event has failures', async () => {
157
+ const snapshot = makeMinimalSnapshot({
158
+ failures: ['https://github.com/o/r/issues/99'],
159
+ });
160
+ mockFetchSse([
161
+ { type: 'progress', fetched: 0, total: 1 },
162
+ { type: 'done', snapshot },
163
+ ]);
164
+
165
+ const { result } = renderHook(() => useAirplaneMode());
166
+
167
+ act(() => {
168
+ result.current.startSync();
169
+ });
170
+
171
+ await waitFor(() => {
172
+ expect(result.current.status).toBe('error');
173
+ });
174
+
175
+ expect(result.current.status).toBe('error');
176
+ expect(result.current.failures).toContain(
177
+ 'https://github.com/o/r/issues/99',
178
+ );
179
+ expect(result.current.snapshot).toBeNull();
180
+ });
181
+
182
+ it('sets status to error when fetch fails', async () => {
183
+ global.fetch = jest.fn().mockRejectedValue(new Error('network down'));
184
+
185
+ const { result } = renderHook(() => useAirplaneMode());
186
+
187
+ act(() => {
188
+ result.current.startSync();
189
+ });
190
+
191
+ await waitFor(() => {
192
+ expect(result.current.status).toBe('error');
193
+ });
194
+
195
+ expect(result.current.failures).toContain('network down');
196
+ });
197
+
198
+ it('turnOff resets to off and clears snapshot', async () => {
199
+ const snapshot = makeMinimalSnapshot();
200
+ mockFetchSse([
201
+ { type: 'progress', fetched: 0, total: 0 },
202
+ { type: 'done', snapshot },
203
+ ]);
204
+
205
+ const { result } = renderHook(() => useAirplaneMode());
206
+
207
+ act(() => {
208
+ result.current.startSync();
209
+ });
210
+
211
+ await waitFor(() => {
212
+ expect(result.current.status).toBe('on');
213
+ });
214
+
215
+ act(() => {
216
+ result.current.turnOff();
217
+ });
218
+
219
+ expect(result.current.status).toBe('off');
220
+ expect(result.current.snapshot).toBeNull();
221
+ expect(result.current.failures).toEqual([]);
222
+ });
223
+
224
+ it('loads stored snapshot from Cache API when flag is on at mount', async () => {
225
+ const snapshot = makeMinimalSnapshot({
226
+ capturedAt: '2025-06-01T00:00:00Z',
227
+ });
228
+ writeAirplaneModeFlag(true);
229
+
230
+ const cacheStore = new Map<string, string>();
231
+ cacheStore.set('snapshot', JSON.stringify(snapshot));
232
+
233
+ const mockCache = {
234
+ put: jest.fn(),
235
+ match: jest.fn().mockResolvedValue(
236
+ new Response(cacheStore.get('snapshot'), {
237
+ headers: { 'Content-Type': 'application/json' },
238
+ }),
239
+ ),
240
+ };
241
+ Object.defineProperty(global, 'caches', {
242
+ writable: true,
243
+ value: {
244
+ open: jest.fn().mockResolvedValue(mockCache),
245
+ delete: jest.fn().mockResolvedValue(true),
246
+ },
247
+ });
248
+
249
+ const { result } = renderHook(() => useAirplaneMode());
250
+
251
+ await waitFor(() => {
252
+ expect(result.current.status).toBe('on');
253
+ });
254
+
255
+ expect(result.current.snapshot?.capturedAt).toBe('2025-06-01T00:00:00Z');
256
+ });
257
+
258
+ it('advances progress events during sync', async () => {
259
+ mockFetchSse([
260
+ { type: 'progress', fetched: 0, total: 2 },
261
+ { type: 'progress', fetched: 1, total: 2 },
262
+ { type: 'progress', fetched: 2, total: 2 },
263
+ { type: 'done', snapshot: makeMinimalSnapshot() },
264
+ ]);
265
+
266
+ const { result } = renderHook(() => useAirplaneMode());
267
+
268
+ act(() => {
269
+ result.current.startSync();
270
+ });
271
+
272
+ await waitFor(() => {
273
+ expect(result.current.progress?.total).toBe(2);
274
+ });
275
+
276
+ await waitFor(() => {
277
+ expect(result.current.status).toBe('on');
278
+ });
279
+ });
280
+
281
+ it('sets status to error and does not write the flag when storeAirplaneSnapshot rejects', async () => {
282
+ Object.defineProperty(global, 'caches', {
283
+ writable: true,
284
+ value: {
285
+ open: jest.fn().mockRejectedValue(new Error('Cache API unavailable')),
286
+ delete: jest.fn().mockResolvedValue(true),
287
+ },
288
+ });
289
+ mockFetchSse([{ type: 'done', snapshot: makeMinimalSnapshot() }]);
290
+
291
+ const { result } = renderHook(() => useAirplaneMode());
292
+
293
+ act(() => {
294
+ result.current.startSync();
295
+ });
296
+
297
+ await waitFor(() => {
298
+ expect(result.current.status).toBe('error');
299
+ });
300
+
301
+ expect(readAirplaneModeFlag()).toBe(false);
302
+ expect(result.current.snapshot).toBeNull();
303
+ });
304
+ });
@@ -0,0 +1,167 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ type AirplaneSnapshot,
4
+ clearAirplaneSnapshot,
5
+ loadAirplaneSnapshot,
6
+ parseAirplaneSnapshot,
7
+ readAirplaneModeFlag,
8
+ storeAirplaneSnapshot,
9
+ writeAirplaneModeFlag,
10
+ } from '../lib/airplaneSnapshot';
11
+
12
+ export type AirplaneModeStatus = 'off' | 'syncing' | 'on' | 'error';
13
+
14
+ export type AirplaneSyncProgress = {
15
+ fetched: number;
16
+ total: number;
17
+ };
18
+
19
+ export type AirplaneModeState = {
20
+ status: AirplaneModeStatus;
21
+ progress: AirplaneSyncProgress | null;
22
+ snapshot: AirplaneSnapshot | null;
23
+ failures: string[];
24
+ startSync: () => void;
25
+ turnOff: () => void;
26
+ };
27
+
28
+ const AIRPLANE_SYNC_PATH = '/api/airplanesync';
29
+
30
+ const parseSyncEvent = (
31
+ raw: string,
32
+ ): { type: string; [key: string]: unknown } | null => {
33
+ try {
34
+ const parsed: unknown = JSON.parse(raw);
35
+ if (
36
+ parsed !== null &&
37
+ typeof parsed === 'object' &&
38
+ !Array.isArray(parsed) &&
39
+ 'type' in parsed &&
40
+ typeof (parsed as { type: unknown }).type === 'string'
41
+ ) {
42
+ return parsed as { type: string; [key: string]: unknown };
43
+ }
44
+ return null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ export const useAirplaneMode = (): AirplaneModeState => {
51
+ const [status, setStatus] = useState<AirplaneModeStatus>('off');
52
+ const [progress, setProgress] = useState<AirplaneSyncProgress | null>(null);
53
+ const [snapshot, setSnapshot] = useState<AirplaneSnapshot | null>(null);
54
+ const [failures, setFailures] = useState<string[]>([]);
55
+ const abortControllerRef = useRef<AbortController | null>(null);
56
+
57
+ useEffect(() => {
58
+ if (!readAirplaneModeFlag()) {
59
+ return;
60
+ }
61
+ let cancelled = false;
62
+ loadAirplaneSnapshot()
63
+ .then((stored) => {
64
+ if (cancelled || stored === null) {
65
+ return;
66
+ }
67
+ setSnapshot(stored);
68
+ setStatus('on');
69
+ })
70
+ .catch(() => {
71
+ writeAirplaneModeFlag(false);
72
+ });
73
+ return () => {
74
+ cancelled = true;
75
+ };
76
+ }, []);
77
+
78
+ const startSync = useCallback((): void => {
79
+ if (abortControllerRef.current !== null) {
80
+ abortControllerRef.current.abort();
81
+ }
82
+ const controller = new AbortController();
83
+ abortControllerRef.current = controller;
84
+
85
+ setStatus('syncing');
86
+ setProgress({ fetched: 0, total: 0 });
87
+ setFailures([]);
88
+
89
+ fetch(AIRPLANE_SYNC_PATH, { signal: controller.signal })
90
+ .then(async (response) => {
91
+ if (!response.ok || response.body === null) {
92
+ setStatus('error');
93
+ setFailures([`HTTP ${response.status}`]);
94
+ return;
95
+ }
96
+
97
+ const reader = response.body
98
+ .pipeThrough(new TextDecoderStream())
99
+ .getReader();
100
+
101
+ let buffer = '';
102
+ for (;;) {
103
+ const { done, value } = await reader.read();
104
+ if (done) {
105
+ break;
106
+ }
107
+ buffer += value;
108
+ const blocks = buffer.split('\n\n');
109
+ buffer = blocks.pop() ?? '';
110
+ for (const block of blocks) {
111
+ if (!block.startsWith('data: ')) {
112
+ continue;
113
+ }
114
+ const event = parseSyncEvent(block.slice('data: '.length));
115
+ if (event === null) {
116
+ continue;
117
+ }
118
+ if (event.type === 'progress') {
119
+ setProgress({
120
+ fetched: typeof event.fetched === 'number' ? event.fetched : 0,
121
+ total: typeof event.total === 'number' ? event.total : 0,
122
+ });
123
+ } else if (event.type === 'done') {
124
+ const parsed = parseAirplaneSnapshot(event.snapshot);
125
+ if (parsed === null) {
126
+ setStatus('error');
127
+ setFailures(['Failed to parse snapshot']);
128
+ return;
129
+ }
130
+ if (parsed.failures.length > 0) {
131
+ setFailures(parsed.failures);
132
+ setStatus('error');
133
+ return;
134
+ }
135
+ await storeAirplaneSnapshot(parsed);
136
+ writeAirplaneModeFlag(true);
137
+ setSnapshot(parsed);
138
+ setStatus('on');
139
+ setFailures([]);
140
+ }
141
+ }
142
+ }
143
+ })
144
+ .catch((error: unknown) => {
145
+ if (error instanceof Error && error.name === 'AbortError') {
146
+ return;
147
+ }
148
+ setStatus('error');
149
+ setFailures([error instanceof Error ? error.message : String(error)]);
150
+ });
151
+ }, []);
152
+
153
+ const turnOff = useCallback((): void => {
154
+ if (abortControllerRef.current !== null) {
155
+ abortControllerRef.current.abort();
156
+ abortControllerRef.current = null;
157
+ }
158
+ setStatus('off');
159
+ setSnapshot(null);
160
+ setProgress(null);
161
+ setFailures([]);
162
+ writeAirplaneModeFlag(false);
163
+ clearAirplaneSnapshot().catch(() => {});
164
+ }, []);
165
+
166
+ return { status, progress, snapshot, failures, startSync, turnOff };
167
+ };
@@ -197,4 +197,19 @@ describe('useConsoleActionQueue', () => {
197
197
  });
198
198
  expect(result.current.error).toBeNull();
199
199
  });
200
+
201
+ it('showError sets the error state without touching pending', () => {
202
+ const { result } = renderHook(() => useConsoleActionQueue());
203
+ act(() => {
204
+ result.current.showError(
205
+ 'Airplane mode',
206
+ 'This action requires a network connection.',
207
+ );
208
+ });
209
+ expect(result.current.error).toEqual({
210
+ message: 'Airplane mode',
211
+ reason: 'This action requires a network connection.',
212
+ });
213
+ expect(result.current.pending).toBeNull();
214
+ });
200
215
  });
@@ -42,6 +42,7 @@ export type ConsoleActionQueue = {
42
42
  pending: ConsolePendingActionView | null;
43
43
  error: ConsoleActionError | null;
44
44
  enqueue: (action: ConsoleQueuedAction) => void;
45
+ showError: (message: string, reason: string) => void;
45
46
  undo: () => void;
46
47
  dismissError: () => void;
47
48
  };
@@ -89,6 +90,10 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
89
90
  setError(null);
90
91
  }, []);
91
92
 
93
+ const showError = useCallback((message: string, reason: string): void => {
94
+ setError({ message, reason });
95
+ }, []);
96
+
92
97
  const enqueue = useCallback(
93
98
  (action: ConsoleQueuedAction): void => {
94
99
  if (actionRef.current !== null && !committedRef.current) {
@@ -126,5 +131,5 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
126
131
 
127
132
  useEffect(() => clearTimer, [clearTimer]);
128
133
 
129
- return { pending, error, enqueue, undo, dismissError };
134
+ return { pending, error, enqueue, showError, undo, dismissError };
130
135
  };
@@ -1,4 +1,5 @@
1
1
  import { renderHook } from '@testing-library/react';
2
+ import type { AirplaneSnapshot } from '../lib/airplaneSnapshot';
2
3
  import { useConsoleCaches } from './useConsoleCaches';
3
4
 
4
5
  describe('useConsoleCaches', () => {
@@ -19,4 +20,80 @@ describe('useConsoleCaches', () => {
19
20
  rerender();
20
21
  expect(result.current).toBe(first);
21
22
  });
23
+
24
+ it('pre-seeds item detail caches from airplane snapshot so item detail renders with network rejecting', () => {
25
+ const prUrl = 'https://github.com/owner/repo/pull/42';
26
+ const airplaneSnapshot: AirplaneSnapshot = {
27
+ capturedAt: '2026-08-01T10:00:00Z',
28
+ failures: [],
29
+ tabs: {},
30
+ items: {
31
+ [prUrl]: {
32
+ body: 'PR body text',
33
+ comments: [
34
+ {
35
+ author: 'alice',
36
+ body: 'LGTM',
37
+ createdAt: '2026-08-01T09:00:00Z',
38
+ },
39
+ ],
40
+ state: {
41
+ state: 'open',
42
+ merged: false,
43
+ isPullRequest: true,
44
+ title: 'Offline PR',
45
+ },
46
+ files: [
47
+ {
48
+ path: 'src/foo.ts',
49
+ additions: 3,
50
+ deletions: 1,
51
+ status: 'modified',
52
+ patch: '@@ -1 +1 @@\n-old\n+new',
53
+ rawUrl: null,
54
+ },
55
+ ],
56
+ commits: [
57
+ {
58
+ sha: 'abc123',
59
+ message: 'fix: something',
60
+ author: 'alice',
61
+ authoredAt: '2026-08-01T08:00:00Z',
62
+ },
63
+ ],
64
+ prStatus: {
65
+ found: true,
66
+ isConflicted: false,
67
+ mergeableStatus: 'MERGEABLE',
68
+ isPassedAllCiJob: true,
69
+ isCiStateSuccess: true,
70
+ isBranchOutOfDate: false,
71
+ missingRequiredCheckNames: [],
72
+ },
73
+ relatedPrs: null,
74
+ },
75
+ },
76
+ };
77
+
78
+ const { result } = renderHook(() => useConsoleCaches(airplaneSnapshot));
79
+
80
+ const cacheKey = 'owner/repo#42';
81
+ expect(result.current.body.peek(cacheKey)).toBe('PR body text');
82
+
83
+ const comments = result.current.comments.peek(cacheKey);
84
+ expect(comments).toHaveLength(1);
85
+ expect(comments?.[0].author).toBe('alice');
86
+
87
+ const files = result.current.files.peek(cacheKey);
88
+ expect(files).toHaveLength(1);
89
+ expect(files?.[0].path).toBe('src/foo.ts');
90
+
91
+ const commits = result.current.commits.peek(cacheKey);
92
+ expect(commits).toHaveLength(1);
93
+ expect(commits?.[0].sha).toBe('abc123');
94
+
95
+ const prStatus = result.current.prStatus.peek(cacheKey);
96
+ expect(prStatus?.isPassedAllCiJob).toBe(true);
97
+ expect(prStatus?.mergeableStatus).toBe('MERGEABLE');
98
+ });
22
99
  });
@@ -1,4 +1,5 @@
1
1
  import { useMemo } from 'react';
2
+ import type { AirplaneSnapshot } from '../lib/airplaneSnapshot';
2
3
  import {
3
4
  type ConsoleApiClient,
4
5
  createConsoleApiClient,
@@ -24,10 +25,50 @@ export type ConsoleCaches = {
24
25
  client: ConsoleApiClient;
25
26
  };
26
27
 
27
- export const useConsoleCaches = (): ConsoleCaches => {
28
+ const deriveCacheKey = (url: string): string => {
29
+ try {
30
+ const urlObj = new URL(url);
31
+ const parts = urlObj.pathname.split('/').filter(Boolean);
32
+ if (parts.length >= 4) {
33
+ const owner = parts[0];
34
+ const repoName = parts[1];
35
+ const number = parts[3];
36
+ return `${owner}/${repoName}#${number}`;
37
+ }
38
+ } catch {}
39
+ return url;
40
+ };
41
+
42
+ const populateCachesFromSnapshot = (
43
+ caches: ConsoleCaches,
44
+ snapshot: AirplaneSnapshot,
45
+ ): void => {
46
+ for (const [url, item] of Object.entries(snapshot.items)) {
47
+ const key = deriveCacheKey(url);
48
+ caches.body.seed(key, item.body);
49
+ caches.comments.seed(key, item.comments);
50
+ caches.state.seed(key, item.state);
51
+ if (item.files !== null) {
52
+ caches.files.seed(key, item.files);
53
+ }
54
+ if (item.commits !== null) {
55
+ caches.commits.seed(key, item.commits);
56
+ }
57
+ if (item.prStatus !== null) {
58
+ caches.prStatus.seed(key, item.prStatus);
59
+ }
60
+ if (item.relatedPrs !== null) {
61
+ caches.relatedPrs.seed(key, item.relatedPrs);
62
+ }
63
+ }
64
+ };
65
+
66
+ export const useConsoleCaches = (
67
+ airplaneSnapshot: AirplaneSnapshot | null = null,
68
+ ): ConsoleCaches => {
28
69
  return useMemo(() => {
29
70
  const client = createConsoleApiClient();
30
- return {
71
+ const caches: ConsoleCaches = {
31
72
  client,
32
73
  body: new ResourceCache<string>(client.fetchItemBody),
33
74
  comments: new ResourceCache<ConsoleComment[]>(client.fetchComments),
@@ -41,5 +82,9 @@ export const useConsoleCaches = (): ConsoleCaches => {
41
82
  client.fetchPullRequestStatus,
42
83
  ),
43
84
  };
44
- }, []);
85
+ if (airplaneSnapshot !== null) {
86
+ populateCachesFromSnapshot(caches, airplaneSnapshot);
87
+ }
88
+ return caches;
89
+ }, [airplaneSnapshot]);
45
90
  };