github-issue-tower-defence-management 2.12.1 → 2.14.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 (50) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
  3. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  4. package/bin/domain/usecases/ActionAnnouncementUseCase.js +4 -2
  5. package/bin/domain/usecases/ActionAnnouncementUseCase.js.map +1 -1
  6. package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
  7. package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
  8. package/bin/domain/usecases/CliErrorReportUseCase.js +1 -1
  9. package/bin/domain/usecases/CliErrorReportUseCase.js.map +1 -1
  10. package/bin/domain/usecases/CreateEstimationIssueUseCase.js +2 -1
  11. package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
  12. package/bin/domain/usecases/DailySecurityScanUseCase.js +3 -3
  13. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  14. package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
  15. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +4 -0
  17. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  18. package/bin/domain/usecases/SubscriptionDisabledIssueUseCase.js +1 -1
  19. package/bin/domain/usecases/SubscriptionDisabledIssueUseCase.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.stories.tsx +115 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.test.tsx +262 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/operations/ConsoleOfflinePendingActionsPanel.tsx +123 -0
  24. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.test.ts +283 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleActionQueue.ts +146 -4
  26. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleOperations.ts +28 -1
  27. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +2 -2
  28. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +103 -1
  29. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +128 -0
  30. package/src/adapter/entry-points/console/ui-dist/assets/{index-D6QOQvoN.js → index-BTFgj8n5.js} +31 -31
  31. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  32. package/src/domain/usecases/ActionAnnouncementUseCase.test.ts +91 -0
  33. package/src/domain/usecases/ActionAnnouncementUseCase.ts +4 -2
  34. package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +2 -0
  35. package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
  36. package/src/domain/usecases/CliErrorReportUseCase.test.ts +11 -0
  37. package/src/domain/usecases/CliErrorReportUseCase.ts +1 -1
  38. package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +2 -0
  39. package/src/domain/usecases/CreateEstimationIssueUseCase.ts +2 -1
  40. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +6 -0
  41. package/src/domain/usecases/DailySecurityScanUseCase.ts +3 -3
  42. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +25 -0
  43. package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
  44. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +20 -0
  45. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -0
  46. package/src/domain/usecases/SubscriptionDisabledIssueUseCase.test.ts +3 -0
  47. package/src/domain/usecases/SubscriptionDisabledIssueUseCase.ts +1 -1
  48. package/types/domain/usecases/ActionAnnouncementUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -1
  50. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
@@ -1,14 +1,33 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { postConsoleOperation } from '../lib/consoleApi';
2
3
  import {
3
4
  ACTION_TOAST_DELAY_MS,
4
5
  type ConsoleToastColor,
5
6
  } from '../logic/actionToast';
6
7
 
8
+ export type ConsoleOfflinePayload = {
9
+ itemUrl: string;
10
+ projectItemId: string;
11
+ itemNumber: number;
12
+ repo: string;
13
+ isPr: boolean;
14
+ apiPath: string;
15
+ requestBody: Record<string, unknown>;
16
+ };
17
+
18
+ export type ConsoleOfflineQueuedAction = {
19
+ id: string;
20
+ message: string;
21
+ color: ConsoleToastColor;
22
+ enqueuedAt: number;
23
+ } & ConsoleOfflinePayload;
24
+
7
25
  export type ConsoleQueuedAction = {
8
26
  message: string;
9
27
  color: ConsoleToastColor;
10
28
  commit: () => Promise<void>;
11
29
  advance: () => void;
30
+ offline?: ConsoleOfflinePayload;
12
31
  };
13
32
 
14
33
  export type ConsolePendingActionView = {
@@ -23,6 +42,57 @@ export type ConsoleActionError = {
23
42
  reason: string;
24
43
  };
25
44
 
45
+ const OFFLINE_QUEUE_STORAGE_KEY = 'console-offline-action-queue';
46
+
47
+ const isConsoleOfflineQueuedAction = (
48
+ item: unknown,
49
+ ): item is ConsoleOfflineQueuedAction => {
50
+ if (typeof item !== 'object' || item === null) return false;
51
+ const a = item as Record<string, unknown>;
52
+ return (
53
+ typeof a.id === 'string' &&
54
+ typeof a.message === 'string' &&
55
+ typeof a.color === 'string' &&
56
+ typeof a.enqueuedAt === 'number' &&
57
+ typeof a.itemUrl === 'string' &&
58
+ typeof a.projectItemId === 'string' &&
59
+ typeof a.itemNumber === 'number' &&
60
+ typeof a.repo === 'string' &&
61
+ typeof a.isPr === 'boolean' &&
62
+ typeof a.apiPath === 'string' &&
63
+ typeof a.requestBody === 'object' &&
64
+ a.requestBody !== null
65
+ );
66
+ };
67
+
68
+ const loadOfflineQueue = (): ConsoleOfflineQueuedAction[] => {
69
+ try {
70
+ const raw = localStorage.getItem(OFFLINE_QUEUE_STORAGE_KEY);
71
+ if (raw === null) return [];
72
+ const parsed: unknown = JSON.parse(raw);
73
+ if (!Array.isArray(parsed)) return [];
74
+ return parsed.filter(isConsoleOfflineQueuedAction);
75
+ } catch {
76
+ return [];
77
+ }
78
+ };
79
+
80
+ const saveOfflineQueue = (actions: ConsoleOfflineQueuedAction[]): void => {
81
+ localStorage.setItem(OFFLINE_QUEUE_STORAGE_KEY, JSON.stringify(actions));
82
+ };
83
+
84
+ const generateId = (): string => {
85
+ if (
86
+ typeof crypto !== 'undefined' &&
87
+ typeof crypto.randomUUID === 'function'
88
+ ) {
89
+ return crypto.randomUUID();
90
+ }
91
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
92
+ };
93
+
94
+ const isNetworkError = (error: unknown): boolean => error instanceof TypeError;
95
+
26
96
  const errorReason = (error: unknown): string => {
27
97
  if (error instanceof Error && error.message.length > 0) {
28
98
  return error.message;
@@ -41,15 +111,24 @@ const computeProgress = (elapsedMs: number): number =>
41
111
  export type ConsoleActionQueue = {
42
112
  pending: ConsolePendingActionView | null;
43
113
  error: ConsoleActionError | null;
114
+ offlineActions: ConsoleOfflineQueuedAction[];
44
115
  enqueue: (action: ConsoleQueuedAction) => void;
45
116
  showError: (message: string, reason: string) => void;
46
117
  undo: () => void;
47
118
  dismissError: () => void;
119
+ confirmOfflineAction: (id: string) => Promise<void>;
120
+ discardOfflineAction: (id: string) => void;
48
121
  };
49
122
 
50
123
  export const useConsoleActionQueue = (): ConsoleActionQueue => {
51
124
  const [pending, setPending] = useState<ConsolePendingActionView | null>(null);
52
125
  const [error, setError] = useState<ConsoleActionError | null>(null);
126
+ const [offlineActions, setOfflineActions] =
127
+ useState<ConsoleOfflineQueuedAction[]>(loadOfflineQueue);
128
+ const offlineActionsRef =
129
+ useRef<ConsoleOfflineQueuedAction[]>(offlineActions);
130
+ offlineActionsRef.current = offlineActions;
131
+
53
132
  const actionRef = useRef<ConsoleQueuedAction | null>(null);
54
133
  const startRef = useRef<number>(0);
55
134
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -62,12 +141,35 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
62
141
  }
63
142
  }, []);
64
143
 
65
- const runCommit = useCallback((action: ConsoleQueuedAction): void => {
66
- action.commit().catch((cause: unknown) => {
67
- setError({ message: action.message, reason: errorReason(cause) });
144
+ const addToOfflineQueue = useCallback((action: ConsoleQueuedAction): void => {
145
+ if (action.offline === undefined) return;
146
+ const offlineAction: ConsoleOfflineQueuedAction = {
147
+ id: generateId(),
148
+ message: action.message,
149
+ color: action.color,
150
+ enqueuedAt: Date.now(),
151
+ ...action.offline,
152
+ };
153
+ setOfflineActions((prev) => {
154
+ const next = [...prev, offlineAction];
155
+ saveOfflineQueue(next);
156
+ return next;
68
157
  });
69
158
  }, []);
70
159
 
160
+ const runCommit = useCallback(
161
+ (action: ConsoleQueuedAction): void => {
162
+ action.commit().catch((cause: unknown) => {
163
+ if (isNetworkError(cause) && action.offline !== undefined) {
164
+ addToOfflineQueue(action);
165
+ } else {
166
+ setError({ message: action.message, reason: errorReason(cause) });
167
+ }
168
+ });
169
+ },
170
+ [addToOfflineQueue],
171
+ );
172
+
71
173
  const commitPending = useCallback((): void => {
72
174
  const action = actionRef.current;
73
175
  clearTimer();
@@ -94,6 +196,36 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
94
196
  setError({ message, reason });
95
197
  }, []);
96
198
 
199
+ const discardOfflineAction = useCallback((id: string): void => {
200
+ setOfflineActions((prev) => {
201
+ const next = prev.filter((a) => a.id !== id);
202
+ saveOfflineQueue(next);
203
+ return next;
204
+ });
205
+ }, []);
206
+
207
+ const confirmOfflineAction = useCallback(
208
+ async (id: string): Promise<void> => {
209
+ const action = offlineActionsRef.current.find((a) => a.id === id);
210
+ if (action === undefined) return;
211
+ try {
212
+ await postConsoleOperation(action.apiPath, action.requestBody);
213
+ discardOfflineAction(id);
214
+ } catch (cause: unknown) {
215
+ if (isNetworkError(cause)) {
216
+ setError({
217
+ message: action.message,
218
+ reason: 'Still offline — action is still held in the queue',
219
+ });
220
+ } else {
221
+ setError({ message: action.message, reason: errorReason(cause) });
222
+ discardOfflineAction(id);
223
+ }
224
+ }
225
+ },
226
+ [discardOfflineAction],
227
+ );
228
+
97
229
  const enqueue = useCallback(
98
230
  (action: ConsoleQueuedAction): void => {
99
231
  if (actionRef.current !== null && !committedRef.current) {
@@ -131,5 +263,15 @@ export const useConsoleActionQueue = (): ConsoleActionQueue => {
131
263
 
132
264
  useEffect(() => clearTimer, [clearTimer]);
133
265
 
134
- return { pending, error, enqueue, showError, undo, dismissError };
266
+ return {
267
+ pending,
268
+ error,
269
+ offlineActions,
270
+ enqueue,
271
+ showError,
272
+ undo,
273
+ dismissError,
274
+ confirmOfflineAction,
275
+ discardOfflineAction,
276
+ };
135
277
  };
@@ -76,7 +76,7 @@ export type ConsoleOperationsApi = {
76
76
  ) => Promise<void>;
77
77
  };
78
78
 
79
- const reviewRequest = (
79
+ export const reviewRequest = (
80
80
  pjcode: string,
81
81
  item: ConsoleListItem,
82
82
  prUrl: string,
@@ -129,6 +129,33 @@ const reviewRequest = (
129
129
  };
130
130
  };
131
131
 
132
+ export const buildTriageRequest = (
133
+ pjcode: string,
134
+ item: ConsoleListItem,
135
+ action:
136
+ | ConsoleNextActionDateAction
137
+ | ConsoleCloseAction
138
+ | 'set_story'
139
+ | 'set_status',
140
+ extra?: { statusName?: string; storyOptionId?: string },
141
+ ): ConsoleTriageRequest => ({
142
+ pjcode,
143
+ action,
144
+ issueUrl: item.url,
145
+ projectItemId: item.projectItemId,
146
+ ...(extra ?? {}),
147
+ });
148
+
149
+ export const buildIntmuxRequest = (
150
+ pjcode: string,
151
+ item: ConsoleListItem,
152
+ ): ConsoleIntmuxRequest => ({
153
+ pjcode,
154
+ action: 'set_intmux',
155
+ issueUrl: item.url,
156
+ projectItemId: item.projectItemId,
157
+ });
158
+
132
159
  const missingPjcodeError = (): Error =>
133
160
  new Error('No project specified in the URL path.');
134
161
 
@@ -293,9 +293,9 @@ const readOperationErrorReason = async (
293
293
  return raw;
294
294
  };
295
295
 
296
- export const postConsoleOperation = async (
296
+ export const postConsoleOperation = async <T extends Record<string, unknown>>(
297
297
  apiPath: string,
298
- body: ConsoleReviewRequest | ConsoleTriageRequest | ConsoleIntmuxRequest,
298
+ body: T,
299
299
  ): Promise<void> => {
300
300
  const response = await fetch(apiPath, {
301
301
  method: 'POST',
@@ -3,16 +3,28 @@ import { ConsoleCommentComposer } from '../components/detail/ConsoleCommentCompo
3
3
  import type { ConsoleAddInlineComment } from '../components/detail/ConsoleFileDiff';
4
4
  import { ConsoleItemDetail } from '../components/detail/ConsoleItemDetail';
5
5
  import { ConsoleOperationMenu } from '../components/operations/ConsoleOperationMenu';
6
+ import type { ConsoleOfflinePayload } from '../hooks/useConsoleActionQueue';
6
7
  import type { ConsoleCaches } from '../hooks/useConsoleCaches';
7
8
  import { useConsoleItemDetailData } from '../hooks/useConsoleItemDetailData';
8
- import type { ConsoleOperationsApi } from '../hooks/useConsoleOperations';
9
+ import {
10
+ buildIntmuxRequest,
11
+ buildTriageRequest,
12
+ type ConsoleOperationsApi,
13
+ INTMUX_OPERATION_PATH,
14
+ REVIEW_OPERATION_PATH,
15
+ reviewRequest,
16
+ TRIAGE_OPERATION_PATH,
17
+ } from '../hooks/useConsoleOperations';
9
18
  import { buildImageProxyUrl } from '../lib/imageProxy';
10
19
  import type { ConsoleActionKind } from '../logic/actionToast';
11
20
  import { resolveStoryColorEnum } from '../logic/grouping';
12
21
  import {
13
22
  AWAITING_WORKSPACE_NAME,
23
+ type ConsoleCloseAction,
24
+ type ConsoleNextActionDateAction,
14
25
  type ConsoleOperationHandlers,
15
26
  type ConsolePendingReviewComment,
27
+ type ConsoleReviewAction,
16
28
  } from '../logic/operations';
17
29
  import { mergePostedComments } from '../logic/postedComments';
18
30
  import type {
@@ -30,13 +42,70 @@ export type ConsoleQueueActionInput = {
30
42
  kind: ConsoleActionKind;
31
43
  item: ConsoleListItem;
32
44
  commit: () => Promise<void>;
45
+ offline?: ConsoleOfflinePayload;
33
46
  };
34
47
 
48
+ const itemOfflineBase = (item: ConsoleListItem) => ({
49
+ itemUrl: item.url,
50
+ projectItemId: item.projectItemId,
51
+ itemNumber: item.number,
52
+ repo: item.repo,
53
+ isPr: item.isPr,
54
+ });
55
+
56
+ const buildReviewOfflinePayload = (
57
+ pjcode: string,
58
+ item: ConsoleListItem,
59
+ prUrl: string,
60
+ action: ConsoleReviewAction,
61
+ pendingComments: ConsolePendingReviewComment[],
62
+ ): ConsoleOfflinePayload => ({
63
+ ...itemOfflineBase(item),
64
+ apiPath: REVIEW_OPERATION_PATH,
65
+ requestBody: reviewRequest(
66
+ pjcode,
67
+ item,
68
+ prUrl,
69
+ action,
70
+ pendingComments,
71
+ ) as unknown as Record<string, unknown>,
72
+ });
73
+
74
+ const buildTriageOfflinePayload = (
75
+ pjcode: string,
76
+ item: ConsoleListItem,
77
+ action:
78
+ | ConsoleNextActionDateAction
79
+ | ConsoleCloseAction
80
+ | 'set_story'
81
+ | 'set_status',
82
+ extra?: { statusName?: string; storyOptionId?: string },
83
+ ): ConsoleOfflinePayload => ({
84
+ ...itemOfflineBase(item),
85
+ apiPath: TRIAGE_OPERATION_PATH,
86
+ requestBody: buildTriageRequest(
87
+ pjcode,
88
+ item,
89
+ action,
90
+ extra,
91
+ ) as unknown as Record<string, unknown>,
92
+ });
93
+
94
+ const buildIntmuxOfflinePayload = (
95
+ pjcode: string,
96
+ item: ConsoleListItem,
97
+ ): ConsoleOfflinePayload => ({
98
+ ...itemOfflineBase(item),
99
+ apiPath: INTMUX_OPERATION_PATH,
100
+ requestBody: buildIntmuxRequest(pjcode, item),
101
+ });
102
+
35
103
  export type ConsoleItemDetailContainerProps = {
36
104
  tab: ConsoleTabName;
37
105
  item: ConsoleListItem;
38
106
  caches: ConsoleCaches;
39
107
  operations: ConsoleOperationsApi;
108
+ pjcode?: string | null;
40
109
  statusOptions: ConsoleFieldOption[];
41
110
  storyColors: ConsoleStoryColorSource;
42
111
  storyName: string | null;
@@ -52,6 +121,7 @@ export const ConsoleItemDetailContainer = ({
52
121
  item,
53
122
  caches,
54
123
  operations,
124
+ pjcode,
55
125
  statusOptions,
56
126
  storyColors,
57
127
  storyName,
@@ -116,6 +186,16 @@ export const ConsoleItemDetailContainer = ({
116
186
  item,
117
187
  commit: () =>
118
188
  operations.reviewPullRequest(item, prUrl, action, reviewComments),
189
+ offline:
190
+ pjcode != null
191
+ ? buildReviewOfflinePayload(
192
+ pjcode,
193
+ item,
194
+ prUrl,
195
+ action,
196
+ reviewComments,
197
+ )
198
+ : undefined,
119
199
  });
120
200
  },
121
201
  onSetNextActionDate: (action) => {
@@ -123,6 +203,10 @@ export const ConsoleItemDetailContainer = ({
123
203
  kind: { type: 'next_action_date', action },
124
204
  item,
125
205
  commit: () => operations.setNextActionDate(item, action),
206
+ offline:
207
+ pjcode != null
208
+ ? buildTriageOfflinePayload(pjcode, item, action)
209
+ : undefined,
126
210
  });
127
211
  },
128
212
  onSetStory: (option: ConsoleFieldOption) => {
@@ -130,6 +214,12 @@ export const ConsoleItemDetailContainer = ({
130
214
  kind: { type: 'set_story', optionName: option.name },
131
215
  item,
132
216
  commit: () => operations.setStory(item, option),
217
+ offline:
218
+ pjcode != null
219
+ ? buildTriageOfflinePayload(pjcode, item, 'set_story', {
220
+ storyOptionId: option.id,
221
+ })
222
+ : undefined,
133
223
  });
134
224
  },
135
225
  onSetStatus: (option: ConsoleFieldOption) => {
@@ -137,6 +227,12 @@ export const ConsoleItemDetailContainer = ({
137
227
  kind: { type: 'set_status', optionName: option.name },
138
228
  item,
139
229
  commit: () => operations.setStatus(item, option),
230
+ offline:
231
+ pjcode != null
232
+ ? buildTriageOfflinePayload(pjcode, item, 'set_status', {
233
+ statusName: option.name,
234
+ })
235
+ : undefined,
140
236
  });
141
237
  },
142
238
  onSetInTmuxByHuman: (option: ConsoleFieldOption) => {
@@ -144,6 +240,8 @@ export const ConsoleItemDetailContainer = ({
144
240
  kind: { type: 'set_in_tmux_by_human', optionName: option.name },
145
241
  item,
146
242
  commit: () => operations.setInTmuxByHuman(item, option),
243
+ offline:
244
+ pjcode != null ? buildIntmuxOfflinePayload(pjcode, item) : undefined,
147
245
  });
148
246
  },
149
247
  onClose: (action) => {
@@ -151,6 +249,10 @@ export const ConsoleItemDetailContainer = ({
151
249
  kind: { type: 'close', action },
152
250
  item,
153
251
  commit: () => operations.closeIssue(item, action),
252
+ offline:
253
+ pjcode != null
254
+ ? buildTriageOfflinePayload(pjcode, item, action)
255
+ : undefined,
154
256
  });
155
257
  },
156
258
  onOkAndAwaitingWorkspace: (option: ConsoleFieldOption) => {
@@ -4,6 +4,10 @@ import { ConsoleTimerSettingsModalDialog } from '../components/layout/ConsoleTim
4
4
  import { ConsoleItemList } from '../components/list/ConsoleItemList';
5
5
  import { ConsoleQueuedList } from '../components/list/ConsoleQueuedList';
6
6
  import { ConsoleStoryList } from '../components/list/ConsoleStoryList';
7
+ import {
8
+ type ConsoleOfflinePendingActionItem,
9
+ ConsoleOfflinePendingActionsPanel,
10
+ } from '../components/operations/ConsoleOfflinePendingActionsPanel';
7
11
  import {
8
12
  ConsoleErrorToast,
9
13
  ConsoleUndoToast,
@@ -52,8 +56,10 @@ import { findNextNonEmptyTabToRight } from '../logic/tabAdvance';
52
56
  import { findNextPjcodeWithMinutes } from '../logic/timerSettings';
53
57
  import type {
54
58
  ConsoleColor,
59
+ ConsoleIssueState,
55
60
  ConsoleListItem,
56
61
  ConsoleOverlayStatus,
62
+ ConsolePullRequestStatus,
57
63
  ConsoleStoryEntry,
58
64
  ConsoleTabName,
59
65
  } from '../logic/types';
@@ -151,6 +157,119 @@ export const ConsolePage = () => {
151
157
  const actionQueue = useConsoleActionQueue();
152
158
  const now = Date.now();
153
159
 
160
+ const [isOnline, setIsOnline] = useState(() => navigator.onLine);
161
+ useEffect(() => {
162
+ const handleOnline = (): void => setIsOnline(true);
163
+ const handleOffline = (): void => setIsOnline(false);
164
+ window.addEventListener('online', handleOnline);
165
+ window.addEventListener('offline', handleOffline);
166
+ return () => {
167
+ window.removeEventListener('online', handleOnline);
168
+ window.removeEventListener('offline', handleOffline);
169
+ };
170
+ }, []);
171
+
172
+ const [offlineItemStates, setOfflineItemStates] = useState<
173
+ Map<string, ConsoleIssueState | null>
174
+ >(new Map());
175
+
176
+ const [offlinePrStatuses, setOfflinePrStatuses] = useState<
177
+ Map<string, ConsolePullRequestStatus | null>
178
+ >(new Map());
179
+
180
+ const [offlineFetchErrors, setOfflineFetchErrors] = useState<Set<string>>(
181
+ new Set(),
182
+ );
183
+
184
+ useEffect(() => {
185
+ if (!isOnline || actionQueue.offlineActions.length === 0) return;
186
+ for (const action of actionQueue.offlineActions) {
187
+ if (offlineItemStates.has(action.id) || offlineFetchErrors.has(action.id))
188
+ continue;
189
+ setOfflineItemStates((prev) => new Map(prev).set(action.id, null));
190
+ caches.client
191
+ .fetchIssueState(action.itemUrl)
192
+ .then((state) => {
193
+ setOfflineItemStates((prev) => new Map(prev).set(action.id, state));
194
+ if (action.isPr) {
195
+ setOfflinePrStatuses((prev) => new Map(prev).set(action.id, null));
196
+ caches.client
197
+ .fetchPullRequestStatus(action.itemUrl)
198
+ .then((prStatus) => {
199
+ setOfflinePrStatuses((prev) =>
200
+ new Map(prev).set(action.id, prStatus),
201
+ );
202
+ })
203
+ .catch((fetchError: unknown) => {
204
+ console.warn(
205
+ 'Failed to fetch PR status for offline action:',
206
+ fetchError,
207
+ );
208
+ });
209
+ }
210
+ })
211
+ .catch((fetchError: unknown) => {
212
+ console.warn(
213
+ 'Failed to fetch current state for offline action:',
214
+ fetchError,
215
+ );
216
+ setOfflineFetchErrors((prev) => new Set(prev).add(action.id));
217
+ });
218
+ }
219
+ }, [
220
+ isOnline,
221
+ actionQueue.offlineActions,
222
+ caches.client,
223
+ offlineItemStates,
224
+ offlineFetchErrors,
225
+ ]);
226
+
227
+ const [isConfirmingOffline, setIsConfirmingOffline] = useState(false);
228
+
229
+ const handleConfirmOffline = useCallback(
230
+ async (id: string): Promise<void> => {
231
+ setIsConfirmingOffline(true);
232
+ try {
233
+ await actionQueue.confirmOfflineAction(id);
234
+ } finally {
235
+ setIsConfirmingOffline(false);
236
+ }
237
+ },
238
+ [actionQueue],
239
+ );
240
+
241
+ const offlinePanelActions = useMemo(
242
+ (): ConsoleOfflinePendingActionItem[] =>
243
+ actionQueue.offlineActions.map((a) => {
244
+ const state = offlineItemStates.get(a.id) ?? null;
245
+ const prStatus = offlinePrStatuses.get(a.id) ?? null;
246
+ const fetchError = offlineFetchErrors.has(a.id);
247
+ return {
248
+ id: a.id,
249
+ message: a.message,
250
+ color: a.color,
251
+ itemNumber: a.itemNumber,
252
+ isPr: a.isPr,
253
+ currentTitle: fetchError ? null : (state?.title ?? null),
254
+ currentState: fetchError
255
+ ? null
256
+ : state === null
257
+ ? null
258
+ : state.merged
259
+ ? 'closed'
260
+ : (state.state as 'open' | 'closed'),
261
+ currentPrStatus: prStatus,
262
+ fetchError,
263
+ };
264
+ }),
265
+ [
266
+ actionQueue.offlineActions,
267
+ offlineItemStates,
268
+ offlinePrStatuses,
269
+ offlineFetchErrors,
270
+ ],
271
+ );
272
+
154
273
  const activeSnapshot = snapshots[activeTab];
155
274
  const pendingItems = useMemo(() => {
156
275
  if (activeSnapshot === null) {
@@ -294,6 +413,7 @@ export const ConsolePage = () => {
294
413
  message: formatActionToast(input.kind, input.item, activeTab),
295
414
  color: actionToastColor(input.kind),
296
415
  commit: input.commit,
416
+ offline: input.offline,
297
417
  advance: () => {
298
418
  if (actionAdvances(input.kind, activeTab)) {
299
419
  if (
@@ -506,6 +626,13 @@ export const ConsolePage = () => {
506
626
  onDismiss={actionQueue.dismissError}
507
627
  />
508
628
  )}
629
+ <ConsoleOfflinePendingActionsPanel
630
+ actions={offlinePanelActions}
631
+ isOnline={isOnline}
632
+ isConfirming={isConfirmingOffline}
633
+ onConfirm={handleConfirmOffline}
634
+ onDiscard={actionQueue.discardOfflineAction}
635
+ />
509
636
  <ConsoleTabList
510
637
  activeTab={activeTab}
511
638
  counts={counts}
@@ -585,6 +712,7 @@ export const ConsolePage = () => {
585
712
  item={selectedItem}
586
713
  caches={caches}
587
714
  operations={operations}
715
+ pjcode={pjcode}
588
716
  statusOptions={statusOptions}
589
717
  storyColors={storyColors}
590
718
  storyName={storyNameForSelected}