@zeniai/client-epic-state 5.2.4 → 5.2.6

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 (64) hide show
  1. package/lib/epic.js +14 -4
  2. package/lib/esm/epic.js +14 -4
  3. package/lib/esm/index.js +4 -3
  4. package/lib/esm/view/accountList/accountListReducer.js +1 -0
  5. package/lib/esm/view/aiCfoView/aiCfoViewReducer.js +411 -9
  6. package/lib/esm/view/aiCfoView/aiCfoViewSelector.js +49 -0
  7. package/lib/esm/view/aiCfoView/epics/deleteSkillEpic.js +23 -0
  8. package/lib/esm/view/aiCfoView/epics/fetchSkillDetailEpic.js +35 -0
  9. package/lib/esm/view/aiCfoView/epics/fetchSkillVersionBodyEpic.js +45 -0
  10. package/lib/esm/view/aiCfoView/epics/fetchSkillVersionsEpic.js +37 -0
  11. package/lib/esm/view/aiCfoView/epics/fetchSkillsEpic.js +11 -6
  12. package/lib/esm/view/aiCfoView/epics/saveSkillEpic.js +72 -0
  13. package/lib/esm/view/aiCfoView/epics/setSkillVisibilityEpic.js +32 -0
  14. package/lib/esm/view/aiCfoView/epics/toSkillDetail.js +102 -0
  15. package/lib/esm/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +8 -0
  16. package/lib/esm/view/invoicing/invoicingQboView/epics/fetchInvoicingAccountingPickersEpic.js +29 -0
  17. package/lib/esm/view/invoicing/invoicingQboView/invoicingQboViewPayload.js +0 -23
  18. package/lib/esm/view/invoicing/invoicingQboView/invoicingQboViewReducer.js +3 -40
  19. package/lib/esm/view/invoicing/invoicingQboView/invoicingQboViewSelector.js +124 -6
  20. package/lib/index.d.ts +7 -5
  21. package/lib/index.js +31 -14
  22. package/lib/view/accountList/accountListReducer.js +1 -0
  23. package/lib/view/accountList/accountListState.d.ts +1 -1
  24. package/lib/view/aiCfoView/aiCfoViewPayload.d.ts +85 -0
  25. package/lib/view/aiCfoView/aiCfoViewReducer.d.ts +67 -2
  26. package/lib/view/aiCfoView/aiCfoViewReducer.js +413 -10
  27. package/lib/view/aiCfoView/aiCfoViewSelector.d.ts +7 -1
  28. package/lib/view/aiCfoView/aiCfoViewSelector.js +54 -0
  29. package/lib/view/aiCfoView/aiCfoViewState.d.ts +143 -1
  30. package/lib/view/aiCfoView/epics/deleteSkillEpic.d.ts +8 -0
  31. package/lib/view/aiCfoView/epics/deleteSkillEpic.js +27 -0
  32. package/lib/view/aiCfoView/epics/fetchSkillDetailEpic.d.ts +8 -0
  33. package/lib/view/aiCfoView/epics/fetchSkillDetailEpic.js +39 -0
  34. package/lib/view/aiCfoView/epics/fetchSkillVersionBodyEpic.d.ts +13 -0
  35. package/lib/view/aiCfoView/epics/fetchSkillVersionBodyEpic.js +49 -0
  36. package/lib/view/aiCfoView/epics/fetchSkillVersionsEpic.d.ts +8 -0
  37. package/lib/view/aiCfoView/epics/fetchSkillVersionsEpic.js +41 -0
  38. package/lib/view/aiCfoView/epics/fetchSkillsEpic.js +11 -6
  39. package/lib/view/aiCfoView/epics/saveSkillEpic.d.ts +14 -0
  40. package/lib/view/aiCfoView/epics/saveSkillEpic.js +76 -0
  41. package/lib/view/aiCfoView/epics/setSkillVisibilityEpic.d.ts +14 -0
  42. package/lib/view/aiCfoView/epics/setSkillVisibilityEpic.js +36 -0
  43. package/lib/view/aiCfoView/epics/toSkillDetail.d.ts +88 -0
  44. package/lib/view/aiCfoView/epics/toSkillDetail.js +110 -0
  45. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.d.ts +9 -0
  46. package/lib/view/invoicing/editInvoicingCatalogItemDetailView/editInvoicingCatalogItemDetailViewSelector.js +8 -0
  47. package/lib/view/invoicing/invoicingQboView/epics/fetchInvoicingAccountingPickersEpic.d.ts +5 -0
  48. package/lib/view/invoicing/invoicingQboView/epics/fetchInvoicingAccountingPickersEpic.js +33 -0
  49. package/lib/view/invoicing/invoicingQboView/epics/invoicingQboViewActionType.d.ts +4 -2
  50. package/lib/view/invoicing/invoicingQboView/invoicingQboViewPayload.d.ts +0 -26
  51. package/lib/view/invoicing/invoicingQboView/invoicingQboViewPayload.js +0 -24
  52. package/lib/view/invoicing/invoicingQboView/invoicingQboViewReducer.d.ts +2 -10
  53. package/lib/view/invoicing/invoicingQboView/invoicingQboViewReducer.js +4 -41
  54. package/lib/view/invoicing/invoicingQboView/invoicingQboViewSelector.d.ts +43 -6
  55. package/lib/view/invoicing/invoicingQboView/invoicingQboViewSelector.js +128 -7
  56. package/lib/view/invoicing/invoicingQboView/invoicingQboViewState.d.ts +1 -5
  57. package/lib/view/taskManager/taskListView/taskList.d.ts +1 -1
  58. package/package.json +1 -1
  59. package/lib/esm/view/invoicing/invoicingQboView/epics/fetchInvoicingQboAccountsEpic.js +0 -25
  60. package/lib/esm/view/invoicing/invoicingQboView/epics/fetchInvoicingQboClassesEpic.js +0 -15
  61. package/lib/view/invoicing/invoicingQboView/epics/fetchInvoicingQboAccountsEpic.d.ts +0 -6
  62. package/lib/view/invoicing/invoicingQboView/epics/fetchInvoicingQboAccountsEpic.js +0 -29
  63. package/lib/view/invoicing/invoicingQboView/epics/fetchInvoicingQboClassesEpic.d.ts +0 -6
  64. package/lib/view/invoicing/invoicingQboView/epics/fetchInvoicingQboClassesEpic.js +0 -19
@@ -48,8 +48,81 @@ export const initialAiCfoViewState = {
48
48
  responseStateByChatSessionId: {},
49
49
  stopSubmitStateByChatSessionId: {},
50
50
  suggestedQuestionsByPageContext: {},
51
- skills: { fetchState: 'Not-Started', error: undefined, skills: [] },
51
+ skillDetail: {
52
+ fetchState: 'Not-Started',
53
+ error: undefined,
54
+ detail: undefined,
55
+ macroId: undefined,
56
+ },
57
+ skillMutation: {
58
+ fetchState: 'Not-Started',
59
+ error: undefined,
60
+ savedMacroId: undefined,
61
+ },
62
+ skillVersionBody: {
63
+ fetchState: 'Not-Started',
64
+ error: undefined,
65
+ instructions: undefined,
66
+ macroId: undefined,
67
+ version: undefined,
68
+ },
69
+ skillVersions: {
70
+ fetchState: 'Not-Started',
71
+ error: undefined,
72
+ versions: [],
73
+ macroId: undefined,
74
+ },
75
+ skills: {
76
+ fetchState: 'Not-Started',
77
+ error: undefined,
78
+ deletedMacroIds: [],
79
+ skills: [],
80
+ skillsEnabled: false,
81
+ canPublish: false,
82
+ },
52
83
  };
84
+ /**
85
+ * Forget the skill that was open — prompt AND history together.
86
+ *
87
+ * Both, always. They are two slices describing one selection, so clearing only the
88
+ * prompt leaves `skillVersions.macroId` matching: an in-flight history response then
89
+ * passes its own stale-response guard and renders the previous skill's versions under
90
+ * whatever the user opened next.
91
+ */
92
+ function clearOpenSkillState(draft) {
93
+ draft.skillDetail = {
94
+ fetchState: 'Not-Started',
95
+ error: undefined,
96
+ detail: undefined,
97
+ macroId: undefined,
98
+ };
99
+ draft.skillVersions = {
100
+ fetchState: 'Not-Started',
101
+ error: undefined,
102
+ versions: [],
103
+ macroId: undefined,
104
+ };
105
+ // Part of the same selection: a pinned body left behind would seed a restore form for
106
+ // a skill the user has left.
107
+ draft.skillVersionBody = {
108
+ fetchState: 'Not-Started',
109
+ error: undefined,
110
+ instructions: undefined,
111
+ macroId: undefined,
112
+ version: undefined,
113
+ };
114
+ }
115
+ /**
116
+ * Take the Skills browser down and drop the prompt it had open.
117
+ *
118
+ * Both halves together, always. Closing without clearing leaves the next open landing on
119
+ * the previous skill instead of the list, and leaves a `macroId` that makes an in-flight
120
+ * detail response look current to the stale-response guard.
121
+ */
122
+ function closeSkillsBrowserState(draft) {
123
+ draft.uiState.isSkillsBrowserOpen = false;
124
+ clearOpenSkillState(draft);
125
+ }
53
126
  const aiCfoView = createSlice({
54
127
  name: 'aiCfoView',
55
128
  initialState: initialAiCfoViewState,
@@ -57,6 +130,16 @@ const aiCfoView = createSlice({
57
130
  submitQuestion: {
58
131
  reducer(draft, action) {
59
132
  const { sessionId } = action.payload;
133
+ // A turn is being sent, so the chat body has to be what the user sees. Doing this
134
+ // here rather than at each call site is deliberate: the composer, the sample
135
+ // chips, Run, and the `/` menu all submit, and every one of them was a separate
136
+ // chance to forget and leave the answer hidden behind the browser.
137
+ //
138
+ // Guarded so a submit with the browser already closed — i.e. almost every submit
139
+ // — does not produce a new `uiState` for every subscriber to re-render on.
140
+ if (draft.uiState.isSkillsBrowserOpen === true) {
141
+ closeSkillsBrowserState(draft);
142
+ }
60
143
  draft.responseStateByChatSessionId[sessionId] = {
61
144
  thought: { fetchState: 'In-Progress', error: undefined },
62
145
  analysis: { fetchState: 'In-Progress', error: undefined },
@@ -183,6 +266,13 @@ const aiCfoView = createSlice({
183
266
  setSession(draft, action) {
184
267
  draft.uiState.currentChatSessionId = action.payload.sessionId;
185
268
  draft.uiState.agentId = action.payload.agentId;
269
+ // Picking a session means wanting to see that chat, so the browser cannot stay over
270
+ // it. In the reducer because there are six dispatch sites across three separate
271
+ // rails, and covering them one at a time missed half of them.
272
+ if (action.payload.isRestore !== true &&
273
+ draft.uiState.isSkillsBrowserOpen === true) {
274
+ closeSkillsBrowserState(draft);
275
+ }
186
276
  mirrorCurrentSessionToPageKeyMapIfActive(draft);
187
277
  },
188
278
  createSession: {
@@ -202,6 +292,12 @@ const aiCfoView = createSlice({
202
292
  draft.uiState.agentId = agentId;
203
293
  draft.createSessionState.fetchState = 'Completed';
204
294
  draft.createSessionState.error = undefined;
295
+ // Same invariant as submitQuestion and initializeNewSessionState: a new chat exists
296
+ // and is now current, so the browser cannot stay over it. This is the fifth entry
297
+ // point to that rule — the others are submit, new-session, New Chat and setSession.
298
+ if (draft.uiState.isSkillsBrowserOpen === true) {
299
+ closeSkillsBrowserState(draft);
300
+ }
205
301
  draft.uiState.currentChatSessionId = sessionId;
206
302
  draft.uiState.lastContextMessage = contextMessage;
207
303
  draft.uiState.lastContextStatus = contextStatus;
@@ -243,6 +339,10 @@ const aiCfoView = createSlice({
243
339
  },
244
340
  initializeNewSessionState(draft, action) {
245
341
  const { agentId, sessionId } = action.payload;
342
+ // Same invariant as submitQuestion — this is the new-session half of submitting.
343
+ if (draft.uiState.isSkillsBrowserOpen === true) {
344
+ closeSkillsBrowserState(draft);
345
+ }
246
346
  draft.uiState.currentChatSessionId = sessionId;
247
347
  draft.uiState.agentId = agentId;
248
348
  draft.uiState.chatHistoryNextPageToken[sessionId] = {
@@ -363,6 +463,10 @@ const aiCfoView = createSlice({
363
463
  };
364
464
  draft.allChatSessionIds = draft.allChatSessionIds.filter((id) => id !== chatSessionId);
365
465
  if (draft.uiState.currentChatSessionId === chatSessionId) {
466
+ // Deliberately does NOT close the Skills browser, unlike the other writers of
467
+ // this field. Deleting a chat from the history list is housekeeping: if the
468
+ // browser is open the user is not looking at that chat, and closing it would
469
+ // yank them out of what they were doing to reveal an empty chat area.
366
470
  draft.uiState.currentChatSessionId = undefined;
367
471
  draft.uiState.agentId = undefined;
368
472
  }
@@ -415,6 +519,10 @@ const aiCfoView = createSlice({
415
519
  },
416
520
  },
417
521
  clearCurrentSessionId(draft) {
522
+ // New Chat: the empty composer is the point, so the browser cannot stay over it.
523
+ if (draft.uiState.isSkillsBrowserOpen === true) {
524
+ closeSkillsBrowserState(draft);
525
+ }
418
526
  draft.uiState.currentChatSessionId = undefined;
419
527
  const key = draft.uiState.aiCfoSidePanelHostPageKey;
420
528
  if (key != null) {
@@ -431,8 +539,24 @@ const aiCfoView = createSlice({
431
539
  draft.uiState.chatSessionIdByPageKey[prevKey] = curId;
432
540
  }
433
541
  draft.uiState.aiCfoSidePanelHostPageKey = newAiCfoSidePanelHostPageKey;
434
- draft.uiState.currentChatSessionId =
435
- nextCurrentChatSessionId != null ? nextCurrentChatSessionId : undefined;
542
+ const nextId = nextCurrentChatSessionId != null ? nextCurrentChatSessionId : undefined;
543
+ // The sixth entry point to the same invariant as submitQuestion,
544
+ // initializeNewSessionState, clearCurrentSessionId, setSession and
545
+ // createSessionSuccess: a different chat is now visible, so the browser cannot stay
546
+ // over it. Missing here, this path swapped the session underneath an open browser
547
+ // and left its macroId looking current to the stale-response guards.
548
+ //
549
+ // Only when the session actually CHANGES, and only when there WAS one. A host page
550
+ // resolving back to the same chat has not changed what is on screen. And
551
+ // `undefined` becoming a saved id is a hydrate, not a navigation — the same
552
+ // settle/restore race `setSession` exempts via `isRestore`: opening Skills on a
553
+ // host page whose chat has not resolved yet would otherwise close the browser
554
+ // under the user the moment the sessions list arrives.
555
+ const hadASession = draft.uiState.currentChatSessionId != null;
556
+ if (hadASession && nextId !== draft.uiState.currentChatSessionId) {
557
+ closeSkillsBrowserState(draft);
558
+ }
559
+ draft.uiState.currentChatSessionId = nextId;
436
560
  draft.uiState.currentInput = '';
437
561
  mirrorCurrentSessionToPageKeyMapIfActive(draft);
438
562
  },
@@ -477,28 +601,306 @@ const aiCfoView = createSlice({
477
601
  };
478
602
  },
479
603
  fetchSkills(draft) {
480
- // Optional-chain: persisted/rehydrated state from before this slice existed
481
- // may have no `skills`, so don't assume draft.skills is set.
604
+ // Read straight off `draft.skills` — no optional chain. This store is never
605
+ // persisted or rehydrated (no redux-persist anywhere in the repo, and no
606
+ // `preloadedState` in any consumer), and `initialAiCfoViewState` gives every skills
607
+ // slice a concrete value, so an undefined slice is unreachable here rather than
608
+ // merely unlikely. An earlier version of this comment claimed the opposite and
609
+ // justified a guard by a mechanism that does not exist — which then got applied to
610
+ // some accesses and not others, so the file read as if the slice were sometimes
611
+ // missing and sometimes not. The one guard that IS meaningful lives in the
612
+ // selectors, where `state.aiCfoViewState` may genuinely be absent for a host that
613
+ // composes a partial root reducer; that is a boundary, and this is not.
482
614
  draft.skills = {
483
615
  fetchState: 'In-Progress',
484
616
  error: undefined,
485
- skills: draft.skills?.skills ?? [],
617
+ skills: draft.skills.skills,
618
+ skillsEnabled: draft.skills.skillsEnabled,
619
+ // Carried for the same reason as skillsEnabled: a refetch in flight is not a
620
+ // reason to hide the publish control the user was just looking at.
621
+ canPublish: draft.skills.canPublish,
622
+ // Carried, not cleared: the response this is about to fetch may still contain a
623
+ // skill deleted moments ago, which is the whole reason the list is kept.
624
+ deletedMacroIds: draft.skills.deletedMacroIds,
486
625
  };
487
626
  },
488
627
  fetchSkillsSuccess(draft, action) {
628
+ // A list response can predate a delete — a save emits fetchSkills, and a delete can
629
+ // land while that is still outstanding — so replacing the array wholesale would put
630
+ // the deleted row back and read as the delete having failed.
489
631
  draft.skills = {
490
632
  fetchState: 'Completed',
491
633
  error: undefined,
492
- skills: action.payload.skills,
634
+ skills: action.payload.skills.filter((skill) => !draft.skills.deletedMacroIds.includes(skill.macroId)),
635
+ skillsEnabled: action.payload.skillsEnabled,
636
+ canPublish: action.payload.canPublish,
637
+ // Applied ONCE, then forgotten — the tombstone covers a race window, not a
638
+ // permanent state.
639
+ //
640
+ // The previous rule (keep it until a response omits that skill) was wrong in the
641
+ // case it was written for: nothing refetches after a delete, and a create emits
642
+ // fetchSkills whose response DOES contain the id, so the tombstone was kept and
643
+ // reapplied forever. A skill recreated under the same id stayed invisible with no
644
+ // way to clear it.
645
+ deletedMacroIds: [],
493
646
  };
494
647
  },
495
648
  fetchSkillsFailure(draft, action) {
496
649
  draft.skills = {
497
650
  fetchState: 'Error',
498
651
  error: action.payload,
499
- skills: draft.skills?.skills ?? [],
652
+ skills: draft.skills.skills,
653
+ // Kept, not reset: a failed refresh is not evidence the feature was turned
654
+ // off, and dropping it to false would make the Skills entry vanish on any
655
+ // transient error.
656
+ skillsEnabled: draft.skills.skillsEnabled,
657
+ // Same reasoning again: a transient error must not revoke a privilege the server
658
+ // already granted, or the control flickers away on every failed refresh.
659
+ canPublish: draft.skills.canPublish,
660
+ // Same reasoning: a failed refresh is not confirmation the delete was undone.
661
+ deletedMacroIds: draft.skills.deletedMacroIds,
662
+ };
663
+ },
664
+ fetchSkillDetail(draft, action) {
665
+ // Opening a skill IS the selection change, so forget the previous one whole —
666
+ // history included. Resetting only the prompt left `skillVersions.macroId` pointing
667
+ // at the skill just navigated away from, so a history response still in flight for
668
+ // it passed its own guard and rendered under the new title. Close, back and delete
669
+ // all go through the same helper for the same reason.
670
+ clearOpenSkillState(draft);
671
+ draft.skillDetail = {
672
+ fetchState: 'In-Progress',
673
+ error: undefined,
674
+ // Cleared rather than kept: holding the previous skill's prompt while a new
675
+ // one loads would show the wrong instructions under the right title.
676
+ detail: undefined,
677
+ macroId: action.payload.macroId,
678
+ };
679
+ },
680
+ fetchSkillDetailSuccess(draft, action) {
681
+ // Ignore a response for a skill the user has already navigated away from.
682
+ if (draft.skillDetail.macroId !== action.payload.macroId) {
683
+ return;
684
+ }
685
+ draft.skillDetail = {
686
+ fetchState: 'Completed',
687
+ error: undefined,
688
+ detail: action.payload.detail,
689
+ macroId: action.payload.macroId,
690
+ };
691
+ },
692
+ fetchSkillDetailFailure(draft, action) {
693
+ if (draft.skillDetail.macroId !== action.payload.macroId) {
694
+ return;
695
+ }
696
+ draft.skillDetail = {
697
+ fetchState: 'Error',
698
+ error: action.payload.error,
699
+ detail: undefined,
700
+ macroId: action.payload.macroId,
500
701
  };
501
702
  },
703
+ clearSkillDetail(draft) {
704
+ // Back to the list. History belongs to the detail view, so it goes too.
705
+ clearOpenSkillState(draft);
706
+ },
707
+ fetchSkillVersions: {
708
+ reducer(draft, action) {
709
+ draft.skillVersions = {
710
+ fetchState: 'In-Progress',
711
+ error: undefined,
712
+ // Cleared, not kept: showing the previous skill's history under a new title
713
+ // is worse than showing none while this loads.
714
+ versions: [],
715
+ macroId: action.payload.macroId,
716
+ };
717
+ },
718
+ prepare(payload) {
719
+ return { payload };
720
+ },
721
+ },
722
+ fetchSkillVersionsSuccess(draft, action) {
723
+ // Same stale-response guard as skillDetail: a switchMap cancels the request but
724
+ // cannot un-dispatch one already in flight.
725
+ if (draft.skillVersions.macroId !== action.payload.macroId) {
726
+ return;
727
+ }
728
+ draft.skillVersions.fetchState = 'Completed';
729
+ draft.skillVersions.error = undefined;
730
+ draft.skillVersions.versions = action.payload.versions;
731
+ },
732
+ fetchSkillVersionsFailure(draft, action) {
733
+ if (draft.skillVersions.macroId !== action.payload.macroId) {
734
+ return;
735
+ }
736
+ draft.skillVersions.fetchState = 'Error';
737
+ draft.skillVersions.error = action.payload.error;
738
+ },
739
+ fetchSkillVersionBody: {
740
+ reducer(draft, action) {
741
+ draft.skillVersionBody = {
742
+ fetchState: 'In-Progress',
743
+ error: undefined,
744
+ // Cleared: seeding a restore form from the PREVIOUS version's body is the same
745
+ // class of mistake as showing one skill's prompt under another's title.
746
+ instructions: undefined,
747
+ macroId: action.payload.macroId,
748
+ version: action.payload.version,
749
+ };
750
+ },
751
+ prepare(payload) {
752
+ return { payload };
753
+ },
754
+ },
755
+ fetchSkillVersionBodySuccess(draft, action) {
756
+ // Both parts of the key: a user can jump between versions of the same skill, so
757
+ // macroId alone would let an earlier version's body land on a later request.
758
+ if (draft.skillVersionBody.macroId !== action.payload.macroId ||
759
+ draft.skillVersionBody.version !== action.payload.version) {
760
+ return;
761
+ }
762
+ draft.skillVersionBody.fetchState = 'Completed';
763
+ draft.skillVersionBody.error = undefined;
764
+ draft.skillVersionBody.instructions = action.payload.instructions;
765
+ },
766
+ fetchSkillVersionBodyFailure(draft, action) {
767
+ if (draft.skillVersionBody.macroId !== action.payload.macroId ||
768
+ draft.skillVersionBody.version !== action.payload.version) {
769
+ return;
770
+ }
771
+ draft.skillVersionBody.fetchState = 'Error';
772
+ draft.skillVersionBody.error = action.payload.error;
773
+ },
774
+ saveSkill: {
775
+ reducer(draft) {
776
+ draft.skillMutation = {
777
+ fetchState: 'In-Progress',
778
+ error: undefined,
779
+ savedMacroId: undefined,
780
+ };
781
+ },
782
+ prepare(payload) {
783
+ return { payload };
784
+ },
785
+ },
786
+ saveSkillSuccess(draft, action) {
787
+ draft.skillMutation.fetchState = 'Completed';
788
+ draft.skillMutation.error = undefined;
789
+ draft.skillMutation.savedMacroId = action.payload.macroId;
790
+ // ONLY when the saved skill is still the one on screen. The save is a mergeMap —
791
+ // deliberately not cancellable, because cancelling a write only hides its outcome —
792
+ // so it can land after the user opened another skill, went back to the list, closed
793
+ // the browser, or deleted it. Writing unconditionally would drop one skill's prompt
794
+ // on top of another's, which is precisely what the fetch guards exist to prevent.
795
+ //
796
+ // A create lands here with nothing open, and is deliberately NOT force-opened:
797
+ // `savedMacroId` is the signal for "what did I just create", and the caller decides
798
+ // whether to open it.
799
+ if (draft.skillDetail.macroId !== action.payload.macroId) {
800
+ return;
801
+ }
802
+ // The save response IS a detail read, so adopt it. Discarding it left an editor
803
+ // sitting on the same skill showing the prompt and card it had BEFORE the save —
804
+ // the user's own change missing from the thing they just saved.
805
+ draft.skillDetail = {
806
+ fetchState: 'Completed',
807
+ error: undefined,
808
+ detail: action.payload.detail,
809
+ macroId: action.payload.macroId,
810
+ };
811
+ // History is now a version behind; the epic refetches it.
812
+ draft.skillVersions = {
813
+ fetchState: 'Not-Started',
814
+ error: undefined,
815
+ versions: [],
816
+ macroId: undefined,
817
+ };
818
+ },
819
+ saveSkillFailure(draft, action) {
820
+ draft.skillMutation.fetchState = 'Error';
821
+ draft.skillMutation.error = action.payload;
822
+ },
823
+ setSkillVisibility: {
824
+ reducer(draft) {
825
+ // Shares skillMutation with save and delete. One in-flight write at a time is the
826
+ // existing contract here, and publishing is a write like any other.
827
+ draft.skillMutation = {
828
+ fetchState: 'In-Progress',
829
+ error: undefined,
830
+ savedMacroId: undefined,
831
+ };
832
+ },
833
+ prepare(payload) {
834
+ return { payload };
835
+ },
836
+ },
837
+ setSkillVisibilitySuccess(draft, action) {
838
+ draft.skillMutation.fetchState = 'Completed';
839
+ draft.skillMutation.error = undefined;
840
+ draft.skillMutation.savedMacroId = action.payload.macroId;
841
+ // Same staleness guard as saveSkillSuccess, for the same reason: this is a mergeMap
842
+ // write, so it can land after the user opened another skill.
843
+ if (draft.skillDetail.macroId !== action.payload.macroId) {
844
+ return;
845
+ }
846
+ draft.skillDetail = {
847
+ fetchState: 'Completed',
848
+ error: undefined,
849
+ detail: action.payload.detail,
850
+ macroId: action.payload.macroId,
851
+ };
852
+ // Deliberately NOT clearing skillVersions, unlike saveSkillSuccess. No version was
853
+ // created, so the history the user is looking at is still correct — refetching it
854
+ // would flash a loading state for a list that did not change.
855
+ },
856
+ setSkillVisibilityFailure(draft, action) {
857
+ draft.skillMutation.fetchState = 'Error';
858
+ draft.skillMutation.error = action.payload;
859
+ },
860
+ deleteSkill: {
861
+ reducer(draft) {
862
+ draft.skillMutation = {
863
+ fetchState: 'In-Progress',
864
+ error: undefined,
865
+ savedMacroId: undefined,
866
+ };
867
+ },
868
+ prepare(payload) {
869
+ return { payload };
870
+ },
871
+ },
872
+ deleteSkillSuccess(draft, action) {
873
+ draft.skillMutation.fetchState = 'Completed';
874
+ draft.skillMutation.error = undefined;
875
+ // Drop it from the list immediately rather than waiting for a refetch: the row
876
+ // the user just deleted staying on screen reads as the delete having failed.
877
+ draft.skills.skills = draft.skills.skills.filter((skill) => skill.macroId !== action.payload.macroId);
878
+ if (!draft.skills.deletedMacroIds.includes(action.payload.macroId)) {
879
+ draft.skills.deletedMacroIds.push(action.payload.macroId);
880
+ }
881
+ if (draft.skillDetail.macroId === action.payload.macroId) {
882
+ clearOpenSkillState(draft);
883
+ }
884
+ },
885
+ deleteSkillFailure(draft, action) {
886
+ draft.skillMutation.fetchState = 'Error';
887
+ draft.skillMutation.error = action.payload;
888
+ },
889
+ clearSkillMutation(draft) {
890
+ draft.skillMutation = {
891
+ fetchState: 'Not-Started',
892
+ error: undefined,
893
+ savedMacroId: undefined,
894
+ };
895
+ },
896
+ openSkillsBrowser(draft) {
897
+ draft.uiState.isSkillsBrowserOpen = true;
898
+ },
899
+ closeSkillsBrowser(draft) {
900
+ // Unguarded, unlike the implicit closes: an explicit close should clear a lingering
901
+ // detail even if the flag is already false.
902
+ closeSkillsBrowserState(draft);
903
+ },
502
904
  updateCotCollapsedState(draft, action) {
503
905
  const { isCollapsed, questionAnswerId } = action.payload;
504
906
  draft.uiState.cotCollapsedByQuestionAnswerId[questionAnswerId] =
@@ -511,5 +913,5 @@ const aiCfoView = createSlice({
511
913
  });
512
914
  },
513
915
  });
514
- export const { submitQuestion, submitQuestionSuccess, submitQuestionFailure, updateResponseState, updateAiCfoViewScrollPosition, updateCurrentInput, submitFeedback, submitFeedbackSuccess, submitFeedbackFailure, setSession, createSession, createSessionSuccess, createSessionFailure, createSessionAndSubmit, initializeNewSessionState, createSessionAndSubmitSuccess, createSessionAndSubmitFailure, fetchChatSessionsForUser, fetchChatSessionsForUserSuccess, fetchChatSessionsForUserFailure, fetchChatHistory, fetchChatHistorySuccess, fetchChatHistoryFailure, clearCurrentSessionId, clearLastContextMessage, stopSubmit, stopSubmitSuccess, stopSubmitFailure, stopSubmitQuestion, stopSubmitQuestionSuccess, stopSubmitQuestionFailure, acceptMasterTOS, acceptMasterTOSSuccess, acceptMasterTOSFailure, deleteChatSession, deleteChatSessionSuccess, deleteChatSessionFailure, clearDeleteChatSessionStatus, updateCotCollapsedState, fetchSuggestedQuestions, fetchSuggestedQuestionsSuccess, fetchSuggestedQuestionsFailure, fetchSkills, fetchSkillsSuccess, fetchSkillsFailure, applyAiCfoSidePanelHostPageTransition, clearAiCfoSidePanelHostPageContext, } = aiCfoView.actions;
916
+ export const { submitQuestion, submitQuestionSuccess, submitQuestionFailure, updateResponseState, updateAiCfoViewScrollPosition, updateCurrentInput, submitFeedback, submitFeedbackSuccess, submitFeedbackFailure, setSession, createSession, createSessionSuccess, createSessionFailure, createSessionAndSubmit, initializeNewSessionState, createSessionAndSubmitSuccess, createSessionAndSubmitFailure, fetchChatSessionsForUser, fetchChatSessionsForUserSuccess, fetchChatSessionsForUserFailure, fetchChatHistory, fetchChatHistorySuccess, fetchChatHistoryFailure, clearCurrentSessionId, clearLastContextMessage, stopSubmit, stopSubmitSuccess, stopSubmitFailure, stopSubmitQuestion, stopSubmitQuestionSuccess, stopSubmitQuestionFailure, acceptMasterTOS, acceptMasterTOSSuccess, acceptMasterTOSFailure, deleteChatSession, deleteChatSessionSuccess, deleteChatSessionFailure, clearDeleteChatSessionStatus, updateCotCollapsedState, fetchSuggestedQuestions, fetchSuggestedQuestionsSuccess, fetchSuggestedQuestionsFailure, fetchSkills, fetchSkillsSuccess, fetchSkillsFailure, fetchSkillDetail, fetchSkillDetailSuccess, fetchSkillDetailFailure, clearSkillDetail, clearSkillMutation, deleteSkill, deleteSkillSuccess, deleteSkillFailure, fetchSkillVersionBody, fetchSkillVersionBodySuccess, fetchSkillVersionBodyFailure, fetchSkillVersions, fetchSkillVersionsSuccess, fetchSkillVersionsFailure, saveSkill, saveSkillSuccess, saveSkillFailure, setSkillVisibility, setSkillVisibilitySuccess, setSkillVisibilityFailure, openSkillsBrowser, closeSkillsBrowser, applyAiCfoSidePanelHostPageTransition, clearAiCfoSidePanelHostPageContext, } = aiCfoView.actions;
515
917
  export default aiCfoView.reducer;
@@ -29,7 +29,56 @@ const EMPTY_SKILLS_STATE = {
29
29
  fetchState: 'Not-Started',
30
30
  error: undefined,
31
31
  skills: [],
32
+ // false, so the Skills entry stays hidden until the server confirms the feature
33
+ // is on. Defaulting true would flash a menu at tenants who never get one.
34
+ skillsEnabled: false,
35
+ // false, for the same reason as skillsEnabled: withhold the publish control until the
36
+ // server says this user may use it.
37
+ canPublish: false,
38
+ deletedMacroIds: [],
39
+ };
40
+ const EMPTY_SKILL_DETAIL_STATE = {
41
+ fetchState: 'Not-Started',
42
+ error: undefined,
43
+ detail: undefined,
44
+ macroId: undefined,
45
+ };
46
+ // Same stable-fallback reason as the two above: a selector that built a fresh object
47
+ // each call would re-render every subscriber on every unrelated state change.
48
+ const EMPTY_SKILL_VERSIONS_STATE = {
49
+ fetchState: 'Not-Started',
50
+ error: undefined,
51
+ versions: [],
52
+ macroId: undefined,
53
+ };
54
+ const EMPTY_SKILL_VERSION_BODY_STATE = {
55
+ fetchState: 'Not-Started',
56
+ error: undefined,
57
+ instructions: undefined,
58
+ macroId: undefined,
59
+ version: undefined,
60
+ };
61
+ const EMPTY_SKILL_MUTATION_STATE = {
62
+ fetchState: 'Not-Started',
63
+ error: undefined,
64
+ savedMacroId: undefined,
32
65
  };
33
66
  export function getSkills(state) {
34
67
  return state.aiCfoViewState?.skills ?? EMPTY_SKILLS_STATE;
35
68
  }
69
+ export function getSkillDetail(state) {
70
+ return state.aiCfoViewState?.skillDetail ?? EMPTY_SKILL_DETAIL_STATE;
71
+ }
72
+ export function getSkillVersions(state) {
73
+ return state.aiCfoViewState?.skillVersions ?? EMPTY_SKILL_VERSIONS_STATE;
74
+ }
75
+ export function getSkillVersionBody(state) {
76
+ return (state.aiCfoViewState?.skillVersionBody ?? EMPTY_SKILL_VERSION_BODY_STATE);
77
+ }
78
+ export function getSkillMutation(state) {
79
+ return state.aiCfoViewState?.skillMutation ?? EMPTY_SKILL_MUTATION_STATE;
80
+ }
81
+ /** True only when the server says the feature is on for this tenant. */
82
+ export function getSkillsEnabled(state) {
83
+ return getSkills(state).skillsEnabled;
84
+ }
@@ -0,0 +1,23 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessStatus } from '../../../responsePayload';
4
+ import { deleteSkill, deleteSkillFailure, deleteSkillSuccess, } from '../aiCfoViewReducer';
5
+ /** DELETE /1.0/skills/{macroId} — soft-deletes every version, frees the name. */
6
+ export const deleteSkillEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(deleteSkill.match),
7
+ // mergeMap for the same reason as the save: cancelling a delete does not un-delete
8
+ // it, and two deletes are for different skills anyway.
9
+ mergeMap((action) => {
10
+ const { macroId } = action.payload;
11
+ const url = `${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/skills/${encodeURIComponent(macroId)}`;
12
+ return zeniAPI.deleteAndGetJSON(url).pipe(mergeMap((response) => {
13
+ // isSuccessStatus, NOT isSuccessResponse: the latter also requires a non-null
14
+ // `data`, and a successful DELETE here is a 204 with no body. Using it reported
15
+ // every completed delete as a failure — the row stayed on screen and a retry
16
+ // 404'd on a skill that was already gone. deleteChatSessionEpic uses the status
17
+ // check for the same reason.
18
+ if (isSuccessStatus(response)) {
19
+ return of(deleteSkillSuccess({ macroId }));
20
+ }
21
+ return of(deleteSkillFailure(response.status));
22
+ }), catchError((error) => of(deleteSkillFailure(createZeniAPIStatus('Unexpected Error', 'Delete skill REST API call failed', error instanceof Error ? { message: error.message } : undefined)))));
23
+ }));
@@ -0,0 +1,35 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap, switchMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
4
+ import { fetchSkillDetail, fetchSkillDetailFailure, fetchSkillDetailSuccess, } from '../aiCfoViewReducer';
5
+ import { isSkillDetailPayload, toSkillDetail } from './toSkillDetail';
6
+ // Deliberately minimal: the browser already holds the full Skill from the list, and
7
+ // this response's `skill` block exists so a deep link can open a detail view without
8
+ // the list ever having been fetched.
9
+ /** GET {aiCfoMicroServiceBaseUrl}/1.0/skills/{macroId} — one skill's prompt + card. */
10
+ export const fetchSkillDetailEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchSkillDetail.match),
11
+ // switchMap: clicking through the list fires one of these per skill, and only the
12
+ // one the user is currently looking at matters. The reducer also drops responses
13
+ // whose macroId no longer matches, so a cancelled-but-in-flight request cannot
14
+ // render the wrong prompt under the right title.
15
+ switchMap((action) => {
16
+ const { macroId } = action.payload;
17
+ const url = `${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/skills/${encodeURIComponent(macroId)}`;
18
+ return zeniAPI.getJSON(url).pipe(mergeMap((response) => {
19
+ // Shared mapper and shared guard. This epic used to keep its own `toSkill`
20
+ // and default a missing prompt to '', so the same malformed body showed an
21
+ // empty prompt here and failed the read elsewhere — and `isMine` was nearly
22
+ // added to only two of the three copies.
23
+ if (isSuccessResponse(response) &&
24
+ isSkillDetailPayload(response.data)) {
25
+ return of(fetchSkillDetailSuccess({
26
+ detail: toSkillDetail(response.data),
27
+ macroId,
28
+ }));
29
+ }
30
+ return of(fetchSkillDetailFailure({ error: response.status, macroId }));
31
+ }), catchError((error) => of(fetchSkillDetailFailure({
32
+ error: createZeniAPIStatus('Unexpected Error', 'Fetch skill detail REST API call failed', error instanceof Error ? { message: error.message } : undefined),
33
+ macroId,
34
+ }))));
35
+ }));
@@ -0,0 +1,45 @@
1
+ import { of } from 'rxjs';
2
+ import { catchError, filter, mergeMap, switchMap } from 'rxjs/operators';
3
+ import { createZeniAPIStatus, isSuccessResponse } from '../../../responsePayload';
4
+ import { fetchSkillVersionBody, fetchSkillVersionBodyFailure, fetchSkillVersionBodySuccess, } from '../aiCfoViewReducer';
5
+ import { hasPromptBody } from './toSkillDetail';
6
+ /**
7
+ * GET /1.0/skills/{macroId}?version=N — one historical version's prompt.
8
+ *
9
+ * Only used to seed a restore. The history list carries metadata only, deliberately: a
10
+ * 20-version history would otherwise be a 240kB response for a panel that shows dates.
11
+ */
12
+ export const fetchSkillVersionBodyEpic = (actions$, _state$, zeniAPI) => actions$.pipe(filter(fetchSkillVersionBody.match),
13
+ // switchMap: a read, and only the version the user last clicked matters. The reducer
14
+ // also drops a response whose (macroId, version) no longer matches, so a
15
+ // cancelled-but-in-flight request cannot seed the form with the wrong body.
16
+ switchMap((action) => {
17
+ const { macroId, version } = action.payload;
18
+ const url = `${zeniAPI.apiEndPoints.aiCfoMicroServiceBaseUrl}/1.0/skills/${encodeURIComponent(macroId)}?version=${encodeURIComponent(String(version))}`;
19
+ return zeniAPI.getJSON(url).pipe(mergeMap((response) => {
20
+ // `instructions` checked, not just `data`: the payload TYPE says the field is
21
+ // required, and the network is under no obligation to agree. Without this the
22
+ // epic reported success with `instructions: undefined`, and the browser — which
23
+ // holds the editor closed until a body arrives — showed "Loading this version…"
24
+ // for ever, with no error and no way forward.
25
+ if (isSuccessResponse(response) && hasPromptBody(response.data)) {
26
+ return of(fetchSkillVersionBodySuccess({
27
+ macroId,
28
+ version,
29
+ instructions: response.data.instructions,
30
+ }));
31
+ }
32
+ // A malformed 2xx is a failure. An empty STRING is not malformed and is allowed
33
+ // through: the editor's own minimum-length rule refuses to save it, so the user
34
+ // sees a validation message on a form they can fix rather than a dead end.
35
+ return of(fetchSkillVersionBodyFailure({
36
+ macroId,
37
+ version,
38
+ error: response.status,
39
+ }));
40
+ }), catchError((error) => of(fetchSkillVersionBodyFailure({
41
+ macroId,
42
+ version,
43
+ error: createZeniAPIStatus('Unexpected Error', 'Fetch skill version body REST API call failed', error instanceof Error ? { message: error.message } : undefined),
44
+ }))));
45
+ }));