@lobehub/lobehub 2.0.0-next.254 → 2.0.0-next.256

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 (148) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/locales/en-US/plugin.json +1 -0
  4. package/locales/zh-CN/plugin.json +1 -0
  5. package/package.json +1 -1
  6. package/packages/builtin-tool-notebook/src/client/Placeholder/CreateDocument.tsx +6 -6
  7. package/packages/builtin-tool-notebook/src/client/Render/CreateDocument/DocumentCard.tsx +23 -10
  8. package/packages/builtin-tool-notebook/src/client/Streaming/CreateDocument/index.tsx +1 -1
  9. package/packages/database/src/models/__tests__/agent.test.ts +91 -4
  10. package/packages/database/src/models/agent.ts +15 -7
  11. package/packages/editor-runtime/src/EditorRuntime.ts +1 -1
  12. package/packages/editor-runtime/src/__tests__/EditorRuntime.real.test.ts +65 -4
  13. package/packages/editor-runtime/src/__tests__/__snapshots__/EditorRuntime.real.test.ts.snap +108 -17
  14. package/packages/editor-runtime/src/__tests__/fixtures/remove-then-add.json +636 -0
  15. package/packages/editor-runtime/src/__tests__/fixtures/remove.json +1 -0
  16. package/packages/types/src/agent/agentConfig.ts +8 -8
  17. package/src/app/[variants]/(main)/chat/features/Portal/_layout/Mobile.tsx +2 -1
  18. package/src/app/[variants]/(main)/group/features/Portal/_layout/Mobile.tsx +2 -1
  19. package/src/app/[variants]/(main)/home/_layout/hooks/useCreateMenuItems.tsx +2 -2
  20. package/src/app/[variants]/(main)/page/{features/PageTitle → PageTitle}/index.tsx +0 -1
  21. package/src/app/[variants]/(main)/page/[id]/index.tsx +43 -1
  22. package/src/app/[variants]/(main)/page/_layout/Body/AllPagesDrawer/Content.tsx +15 -15
  23. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/Editing.tsx +3 -3
  24. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/index.tsx +7 -12
  25. package/src/app/[variants]/(main)/page/_layout/Body/List/Item/useDropdownMenu.tsx +5 -5
  26. package/src/app/[variants]/(main)/page/_layout/Body/List/index.tsx +7 -7
  27. package/src/app/[variants]/(main)/page/_layout/Body/index.tsx +15 -9
  28. package/src/app/[variants]/(main)/page/_layout/Body/useDropdownMenu.tsx +3 -3
  29. package/src/app/[variants]/(main)/page/_layout/DataSync.tsx +15 -0
  30. package/src/app/[variants]/(main)/page/_layout/Header/AddButton.tsx +2 -2
  31. package/src/app/[variants]/(main)/page/_layout/index.tsx +2 -0
  32. package/src/app/[variants]/(main)/page/index.tsx +3 -7
  33. package/src/components/Editor/AutoSaveHint.tsx +1 -1
  34. package/src/features/Conversation/Messages/AssistantGroup/Tool/Render/Intervention/ApprovalActions.tsx +2 -2
  35. package/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +18 -15
  36. package/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx +3 -3
  37. package/src/features/Conversation/Messages/Contexts/MessageAggregationContext.ts +15 -0
  38. package/src/features/Conversation/Messages/Supervisor/components/Group.tsx +3 -3
  39. package/src/features/Conversation/Messages/User/Actions/index.tsx +5 -1
  40. package/src/features/EditorCanvas/AutoSaveHint.tsx +37 -0
  41. package/src/features/{PageEditor → EditorCanvas}/DiffAllToolbar.tsx +57 -16
  42. package/src/features/EditorCanvas/DocumentIdMode.tsx +111 -0
  43. package/src/features/EditorCanvas/EditorCanvas.tsx +148 -0
  44. package/src/features/EditorCanvas/EditorDataMode.tsx +64 -0
  45. package/src/features/EditorCanvas/ErrorBoundary.tsx +66 -0
  46. package/src/features/EditorCanvas/InlineToolbar.tsx +245 -0
  47. package/src/features/EditorCanvas/InternalEditor.tsx +134 -0
  48. package/src/features/{PageEditor/EditorCanvas → EditorCanvas}/actions.ts +10 -8
  49. package/src/features/EditorCanvas/index.ts +9 -0
  50. package/src/features/PageEditor/EditorCanvas/index.tsx +14 -111
  51. package/src/features/PageEditor/EditorCanvas/useAskCopilotItem.tsx +95 -0
  52. package/src/features/PageEditor/EditorCanvas/useSlashItems.tsx +1 -1
  53. package/src/features/PageEditor/Header/Breadcrumb.tsx +2 -2
  54. package/src/features/PageEditor/Header/index.tsx +15 -18
  55. package/src/features/PageEditor/Header/useMenu.tsx +12 -9
  56. package/src/features/PageEditor/PageEditor.tsx +45 -21
  57. package/src/features/PageEditor/PageEditorProvider.tsx +13 -1
  58. package/src/features/PageEditor/PageTitle/index.tsx +2 -2
  59. package/src/features/PageEditor/StoreUpdater.tsx +35 -308
  60. package/src/features/PageEditor/{Body/Title.tsx → TitleSection.tsx} +16 -16
  61. package/src/features/PageEditor/store/action.ts +96 -188
  62. package/src/features/PageEditor/store/initialState.ts +16 -21
  63. package/src/features/PageEditor/store/selectors.ts +3 -4
  64. package/src/features/PageExplorer/PageExplorerPlaceholder.tsx +22 -14
  65. package/src/features/PageExplorer/index.tsx +34 -67
  66. package/src/features/Portal/Artifacts/index.ts +0 -2
  67. package/src/features/Portal/Document/AutoSaveHint.tsx +7 -6
  68. package/src/features/Portal/Document/Body.tsx +1 -3
  69. package/src/features/Portal/Document/EditorCanvas.tsx +7 -50
  70. package/src/features/Portal/Document/Header.tsx +13 -10
  71. package/src/features/Portal/Document/TodoList.tsx +6 -4
  72. package/src/features/Portal/Document/Wrapper.tsx +3 -11
  73. package/src/features/Portal/Document/index.ts +0 -2
  74. package/src/features/Portal/FilePreview/index.ts +0 -2
  75. package/src/features/Portal/GroupThread/index.ts +0 -3
  76. package/src/features/Portal/MessageDetail/index.ts +0 -2
  77. package/src/features/Portal/Notebook/index.ts +0 -2
  78. package/src/features/Portal/Plugins/index.ts +0 -2
  79. package/src/features/Portal/Thread/index.ts +0 -3
  80. package/src/features/Portal/components/Header.tsx +18 -6
  81. package/src/features/Portal/router.tsx +34 -97
  82. package/src/features/Portal/type.ts +0 -2
  83. package/src/libs/next/config/define-config.ts +4 -1
  84. package/src/locales/default/plugin.ts +1 -0
  85. package/src/store/chat/slices/portal/action.test.ts +218 -15
  86. package/src/store/chat/slices/portal/action.ts +194 -41
  87. package/src/store/chat/slices/portal/initialState.ts +40 -1
  88. package/src/store/chat/slices/portal/selectors/thread.ts +44 -3
  89. package/src/store/chat/slices/portal/selectors.test.ts +119 -17
  90. package/src/store/chat/slices/portal/selectors.ts +117 -36
  91. package/src/store/document/index.ts +17 -5
  92. package/src/store/document/slices/document/action.ts +209 -0
  93. package/src/store/document/slices/document/index.ts +6 -0
  94. package/src/store/document/slices/editor/action.test.ts +340 -0
  95. package/src/store/document/slices/editor/action.ts +133 -149
  96. package/src/store/document/slices/editor/index.ts +9 -2
  97. package/src/store/document/slices/editor/initialState.ts +66 -29
  98. package/src/store/document/slices/editor/reducer.test.ts +217 -0
  99. package/src/store/document/slices/editor/reducer.ts +67 -0
  100. package/src/store/document/slices/editor/selectors.test.ts +395 -0
  101. package/src/store/document/slices/editor/selectors.ts +107 -5
  102. package/src/store/document/store.ts +12 -13
  103. package/src/store/file/slices/document/action.ts +19 -188
  104. package/src/store/file/slices/document/initialState.ts +0 -30
  105. package/src/store/file/slices/document/selectors.ts +25 -59
  106. package/src/store/notebook/index.ts +5 -4
  107. package/src/store/page/index.ts +2 -0
  108. package/src/store/page/initialState.ts +92 -0
  109. package/src/store/page/selectors.ts +5 -0
  110. package/src/store/page/slices/crud/action.ts +477 -0
  111. package/src/store/page/slices/crud/index.ts +2 -0
  112. package/src/store/page/slices/crud/initialState.ts +7 -0
  113. package/src/store/page/slices/internal/action.ts +32 -0
  114. package/src/store/page/slices/internal/index.ts +2 -0
  115. package/src/store/page/slices/internal/reducer.ts +105 -0
  116. package/src/store/page/slices/list/action.ts +206 -0
  117. package/src/store/page/slices/list/index.ts +3 -0
  118. package/src/store/page/slices/list/initialState.ts +29 -0
  119. package/src/store/page/slices/list/selectors.ts +90 -0
  120. package/src/store/page/slices/selection/action.ts +67 -0
  121. package/src/store/page/slices/selection/index.ts +2 -0
  122. package/src/store/page/slices/selection/initialState.ts +11 -0
  123. package/src/store/page/store.ts +29 -0
  124. package/src/store/tool/slices/lobehubSkillStore/selectors.ts +10 -20
  125. package/src/utils/identifier.ts +8 -2
  126. package/src/features/Conversation/Messages/AssistantGroup/components/GroupContext.ts +0 -15
  127. package/src/features/Conversation/Messages/Supervisor/components/GroupContext.ts +0 -15
  128. package/src/features/PageEditor/Body/index.tsx +0 -68
  129. package/src/features/PageEditor/EditorCanvas/InlineToolbar.tsx +0 -316
  130. package/src/features/PageEditor/Header/AutoSaveHint.tsx +0 -27
  131. package/src/features/Portal/Artifacts/useEnable.ts +0 -4
  132. package/src/features/Portal/Document/DocumentEditorProvider.tsx +0 -34
  133. package/src/features/Portal/Document/StoreUpdater.tsx +0 -80
  134. package/src/features/Portal/Document/Title.tsx +0 -54
  135. package/src/features/Portal/Document/store/action.ts +0 -114
  136. package/src/features/Portal/Document/store/index.ts +0 -21
  137. package/src/features/Portal/Document/store/initialState.ts +0 -24
  138. package/src/features/Portal/Document/useEnable.ts +0 -8
  139. package/src/features/Portal/FilePreview/useEnable.ts +0 -6
  140. package/src/features/Portal/GroupThread/hook.ts +0 -9
  141. package/src/features/Portal/MessageDetail/useEnable.ts +0 -4
  142. package/src/features/Portal/Notebook/useEnable.ts +0 -6
  143. package/src/features/Portal/Plugins/useEnable.ts +0 -6
  144. package/src/features/Portal/Thread/hook.ts +0 -8
  145. package/src/store/document/slices/notebook/action.ts +0 -119
  146. package/src/store/document/slices/notebook/index.ts +0 -3
  147. package/src/store/document/slices/notebook/initialState.ts +0 -12
  148. package/src/store/document/slices/notebook/selectors.ts +0 -26
@@ -3,19 +3,219 @@ import { describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  import { useChatStore } from '@/store/chat';
5
5
 
6
+ import { PortalViewType } from './initialState';
7
+
6
8
  vi.mock('zustand/traditional');
7
9
 
8
10
  describe('chatDockSlice', () => {
11
+ describe('pushPortalView', () => {
12
+ it('should push a new view onto the stack and open portal', () => {
13
+ const { result } = renderHook(() => useChatStore());
14
+
15
+ expect(result.current.portalStack).toEqual([]);
16
+ expect(result.current.showPortal).toBe(false);
17
+
18
+ act(() => {
19
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
20
+ });
21
+
22
+ expect(result.current.portalStack).toHaveLength(1);
23
+ expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
24
+ expect(result.current.showPortal).toBe(true);
25
+ });
26
+
27
+ it('should replace top view when pushing same type', () => {
28
+ const { result } = renderHook(() => useChatStore());
29
+
30
+ act(() => {
31
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
32
+ });
33
+
34
+ expect(result.current.portalStack).toHaveLength(1);
35
+
36
+ act(() => {
37
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-2' });
38
+ });
39
+
40
+ expect(result.current.portalStack).toHaveLength(1);
41
+ expect(result.current.portalStack[0]).toEqual({
42
+ type: PortalViewType.Document,
43
+ documentId: 'doc-2',
44
+ });
45
+ });
46
+
47
+ it('should stack different view types', () => {
48
+ const { result } = renderHook(() => useChatStore());
49
+
50
+ act(() => {
51
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
52
+ });
53
+
54
+ act(() => {
55
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
56
+ });
57
+
58
+ expect(result.current.portalStack).toHaveLength(2);
59
+ expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
60
+ expect(result.current.portalStack[1]).toEqual({
61
+ type: PortalViewType.Document,
62
+ documentId: 'doc-1',
63
+ });
64
+ });
65
+ });
66
+
67
+ describe('popPortalView', () => {
68
+ it('should pop the top view and close portal when stack is empty', () => {
69
+ const { result } = renderHook(() => useChatStore());
70
+
71
+ act(() => {
72
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
73
+ });
74
+
75
+ expect(result.current.showPortal).toBe(true);
76
+
77
+ act(() => {
78
+ result.current.popPortalView();
79
+ });
80
+
81
+ expect(result.current.portalStack).toHaveLength(0);
82
+ expect(result.current.showPortal).toBe(false);
83
+ });
84
+
85
+ it('should pop top view and keep portal open when more views exist', () => {
86
+ const { result } = renderHook(() => useChatStore());
87
+
88
+ act(() => {
89
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
90
+ });
91
+
92
+ act(() => {
93
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
94
+ });
95
+
96
+ expect(result.current.portalStack).toHaveLength(2);
97
+
98
+ act(() => {
99
+ result.current.popPortalView();
100
+ });
101
+
102
+ expect(result.current.portalStack).toHaveLength(1);
103
+ expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
104
+ expect(result.current.showPortal).toBe(true);
105
+ });
106
+ });
107
+
108
+ describe('replacePortalView', () => {
109
+ it('should replace top view with new view', () => {
110
+ const { result } = renderHook(() => useChatStore());
111
+
112
+ act(() => {
113
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
114
+ });
115
+
116
+ act(() => {
117
+ result.current.replacePortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
118
+ });
119
+
120
+ expect(result.current.portalStack).toHaveLength(1);
121
+ expect(result.current.portalStack[0]).toEqual({
122
+ type: PortalViewType.Document,
123
+ documentId: 'doc-1',
124
+ });
125
+ });
126
+
127
+ it('should push view when stack is empty', () => {
128
+ const { result } = renderHook(() => useChatStore());
129
+
130
+ act(() => {
131
+ result.current.replacePortalView({ type: PortalViewType.Notebook });
132
+ });
133
+
134
+ expect(result.current.portalStack).toHaveLength(1);
135
+ expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
136
+ expect(result.current.showPortal).toBe(true);
137
+ });
138
+ });
139
+
140
+ describe('clearPortalStack', () => {
141
+ it('should clear all views and close portal', () => {
142
+ const { result } = renderHook(() => useChatStore());
143
+
144
+ act(() => {
145
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
146
+ });
147
+
148
+ act(() => {
149
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
150
+ });
151
+
152
+ expect(result.current.portalStack).toHaveLength(2);
153
+
154
+ act(() => {
155
+ result.current.clearPortalStack();
156
+ });
157
+
158
+ expect(result.current.portalStack).toHaveLength(0);
159
+ expect(result.current.showPortal).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('goBack', () => {
164
+ it('should pop top view when stack has multiple views', () => {
165
+ const { result } = renderHook(() => useChatStore());
166
+
167
+ act(() => {
168
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
169
+ });
170
+
171
+ act(() => {
172
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
173
+ });
174
+
175
+ act(() => {
176
+ result.current.goBack();
177
+ });
178
+
179
+ expect(result.current.portalStack).toHaveLength(1);
180
+ expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Notebook });
181
+ });
182
+ });
183
+
184
+ describe('goHome', () => {
185
+ it('should replace stack with home view', () => {
186
+ const { result } = renderHook(() => useChatStore());
187
+
188
+ act(() => {
189
+ result.current.pushPortalView({ type: PortalViewType.Notebook });
190
+ });
191
+
192
+ act(() => {
193
+ result.current.pushPortalView({ type: PortalViewType.Document, documentId: 'doc-1' });
194
+ });
195
+
196
+ act(() => {
197
+ result.current.goHome();
198
+ });
199
+
200
+ expect(result.current.portalStack).toHaveLength(1);
201
+ expect(result.current.portalStack[0]).toEqual({ type: PortalViewType.Home });
202
+ expect(result.current.showPortal).toBe(true);
203
+ });
204
+ });
205
+
206
+
9
207
  describe('closeToolUI', () => {
10
- it('should set dockToolMessage to undefined', () => {
208
+ it('should pop ToolUI view from stack', () => {
11
209
  const { result } = renderHook(() => useChatStore());
12
210
 
13
211
  act(() => {
14
212
  result.current.openToolUI('test-id', 'test-identifier');
15
213
  });
16
214
 
17
- expect(result.current.portalToolMessage).toEqual({
18
- id: 'test-id',
215
+ expect(result.current.portalStack).toHaveLength(1);
216
+ expect(result.current.portalStack[0]).toEqual({
217
+ type: PortalViewType.ToolUI,
218
+ messageId: 'test-id',
19
219
  identifier: 'test-identifier',
20
220
  });
21
221
 
@@ -23,12 +223,13 @@ describe('chatDockSlice', () => {
23
223
  result.current.closeToolUI();
24
224
  });
25
225
 
26
- expect(result.current.portalToolMessage).toBeUndefined();
226
+ expect(result.current.portalStack).toHaveLength(0);
227
+ expect(result.current.showPortal).toBe(false);
27
228
  });
28
229
  });
29
230
 
30
231
  describe('openToolUI', () => {
31
- it('should set dockToolMessage and open dock if it is closed', () => {
232
+ it('should push ToolUI view and open portal', () => {
32
233
  const { result } = renderHook(() => useChatStore());
33
234
 
34
235
  expect(result.current.showPortal).toBe(false);
@@ -37,29 +238,31 @@ describe('chatDockSlice', () => {
37
238
  result.current.openToolUI('test-id', 'test-identifier');
38
239
  });
39
240
 
40
- expect(result.current.portalToolMessage).toEqual({
41
- id: 'test-id',
241
+ expect(result.current.portalStack).toHaveLength(1);
242
+ expect(result.current.portalStack[0]).toEqual({
243
+ type: PortalViewType.ToolUI,
244
+ messageId: 'test-id',
42
245
  identifier: 'test-identifier',
43
246
  });
44
247
  expect(result.current.showPortal).toBe(true);
45
248
  });
46
249
 
47
- it('should not change dock state if it is already open', () => {
250
+ it('should replace same type view on stack', () => {
48
251
  const { result } = renderHook(() => useChatStore());
49
252
 
50
253
  act(() => {
51
- result.current.togglePortal(true);
254
+ result.current.openToolUI('test-id-1', 'identifier-1');
52
255
  });
53
256
 
54
- expect(result.current.showPortal).toBe(true);
55
-
56
257
  act(() => {
57
- result.current.openToolUI('test-id', 'test-identifier');
258
+ result.current.openToolUI('test-id-2', 'identifier-2');
58
259
  });
59
260
 
60
- expect(result.current.portalToolMessage).toEqual({
61
- id: 'test-id',
62
- identifier: 'test-identifier',
261
+ expect(result.current.portalStack).toHaveLength(1);
262
+ expect(result.current.portalStack[0]).toEqual({
263
+ type: PortalViewType.ToolUI,
264
+ messageId: 'test-id-2',
265
+ identifier: 'identifier-2',
63
266
  });
64
267
  expect(result.current.showPortal).toBe(true);
65
268
  });
@@ -3,94 +3,247 @@ import { type StateCreator } from 'zustand/vanilla';
3
3
  import { type ChatStore } from '@/store/chat/store';
4
4
  import { type PortalArtifact } from '@/types/artifact';
5
5
 
6
- import { type PortalFile } from './initialState';
6
+ import { type PortalFile, type PortalViewData, PortalViewType } from './initialState';
7
7
 
8
8
  export interface ChatPortalAction {
9
+ // ============== Core Stack Operations ==============
10
+ clearPortalStack: () => void;
11
+ // ============== Convenience Methods ==============
9
12
  closeArtifact: () => void;
10
13
  closeDocument: () => void;
11
14
  closeFilePreview: () => void;
12
15
  closeMessageDetail: () => void;
13
16
  closeNotebook: () => void;
17
+
14
18
  closeToolUI: () => void;
19
+ goBack: () => void;
20
+ goHome: () => void;
15
21
  openArtifact: (artifact: PortalArtifact) => void;
16
22
  openDocument: (documentId: string) => void;
17
- openFilePreview: (portal: PortalFile) => void;
23
+ openFilePreview: (file: PortalFile) => void;
18
24
  openMessageDetail: (messageId: string) => void;
19
25
  openNotebook: () => void;
20
26
  openToolUI: (messageId: string, identifier: string) => void;
27
+ popPortalView: () => void;
28
+ pushPortalView: (view: PortalViewData) => void;
29
+ replacePortalView: (view: PortalViewData) => void;
21
30
  toggleNotebook: (open?: boolean) => void;
22
31
  togglePortal: (open?: boolean) => void;
23
32
  }
24
33
 
34
+ // Helper to get current view type from stack
35
+ const getCurrentViewType = (portalStack: PortalViewData[]): PortalViewType | null => {
36
+ const top = portalStack.at(-1);
37
+ return top?.type ?? null;
38
+ };
39
+
25
40
  export const chatPortalSlice: StateCreator<
26
41
  ChatStore,
27
42
  [['zustand/devtools', never]],
28
43
  [],
29
44
  ChatPortalAction
30
45
  > = (set, get) => ({
31
- closeArtifact: () => {
32
- get().togglePortal(false);
33
- set({ portalArtifact: undefined }, false, 'closeArtifact');
46
+
47
+
48
+ clearPortalStack: () => {
49
+ set({ portalStack: [], showPortal: false }, false, 'clearPortalStack');
34
50
  },
35
- closeDocument: () => {
36
- set({ portalDocumentId: undefined }, false, 'closeDocument');
51
+
52
+
53
+ closeArtifact: () => {
54
+ const { portalStack } = get();
55
+ if (getCurrentViewType(portalStack) === PortalViewType.Artifact) {
56
+ get().popPortalView();
57
+ }
37
58
  },
38
- closeFilePreview: () => {
39
- set({ portalFile: undefined }, false, 'closeFilePreview');
59
+
60
+
61
+ closeDocument: () => {
62
+ const { portalStack } = get();
63
+ if (getCurrentViewType(portalStack) === PortalViewType.Document) {
64
+ get().popPortalView();
65
+ }
40
66
  },
41
- closeMessageDetail: () => {
42
- set({ portalMessageDetail: undefined }, false, 'openMessageDetail');
67
+
68
+
69
+ closeFilePreview: () => {
70
+ const { portalStack } = get();
71
+ if (getCurrentViewType(portalStack) === PortalViewType.FilePreview) {
72
+ get().popPortalView();
73
+ }
43
74
  },
44
- closeNotebook: () => {
45
- set({ showNotebook: false }, false, 'closeNotebook');
75
+
76
+
77
+ closeMessageDetail: () => {
78
+ const { portalStack } = get();
79
+ if (getCurrentViewType(portalStack) === PortalViewType.MessageDetail) {
80
+ get().popPortalView();
81
+ }
46
82
  },
47
- closeToolUI: () => {
48
- set({ portalToolMessage: undefined }, false, 'closeToolUI');
83
+
84
+
85
+ closeNotebook: () => {
86
+ const { portalStack } = get();
87
+ if (getCurrentViewType(portalStack) === PortalViewType.Notebook) {
88
+ get().popPortalView();
89
+ }
49
90
  },
50
- openArtifact: (artifact) => {
51
- get().togglePortal(true);
52
91
 
53
- set({ portalArtifact: artifact }, false, 'openArtifact');
92
+
93
+
94
+
95
+ closeToolUI: () => {
96
+ const { portalStack } = get();
97
+ if (getCurrentViewType(portalStack) === PortalViewType.ToolUI) {
98
+ get().popPortalView();
99
+ }
54
100
  },
55
- openDocument: (documentId) => {
56
- get().togglePortal(true);
57
101
 
58
- set({ portalDocumentId: documentId, showNotebook: true }, false, 'openDocument');
102
+
103
+
104
+ goBack: () => {
105
+ get().popPortalView();
59
106
  },
60
- openFilePreview: (portal) => {
61
- get().togglePortal(true);
62
107
 
63
- set({ portalFile: portal }, false, 'openFilePreview');
108
+
109
+
110
+ goHome: () => {
111
+ set(
112
+ {
113
+ portalStack: [{ type: PortalViewType.Home }],
114
+ showPortal: true,
115
+ },
116
+ false,
117
+ 'goHome',
118
+ );
64
119
  },
65
- openMessageDetail: (messageId) => {
66
- get().togglePortal(true);
67
120
 
68
- set({ portalMessageDetail: messageId }, false, 'openMessageDetail');
121
+
122
+
123
+ // ============== Convenience Methods (using stack operations) ==============
124
+ openArtifact: (artifact) => {
125
+ get().pushPortalView({ artifact, type: PortalViewType.Artifact });
69
126
  },
70
127
 
71
- openNotebook: () => {
72
- get().togglePortal(true);
73
128
 
74
- set({ showNotebook: true }, false, 'openNotebook');
129
+
130
+
131
+ openDocument: (documentId) => {
132
+ get().pushPortalView({ documentId, type: PortalViewType.Document });
75
133
  },
76
134
 
77
- openToolUI: (id, identifier) => {
78
- get().togglePortal(true);
79
135
 
80
- set({ portalToolMessage: { id, identifier } }, false, 'openToolUI');
136
+
137
+
138
+ openFilePreview: (file) => {
139
+ get().pushPortalView({ file, type: PortalViewType.FilePreview });
140
+ },
141
+
142
+
143
+
144
+ openMessageDetail: (messageId) => {
145
+ get().pushPortalView({ messageId, type: PortalViewType.MessageDetail });
146
+ },
147
+
148
+
149
+ openNotebook: () => {
150
+ get().pushPortalView({ type: PortalViewType.Notebook });
151
+ },
152
+
153
+
154
+ openToolUI: (messageId, identifier) => {
155
+ get().pushPortalView({ identifier, messageId, type: PortalViewType.ToolUI });
156
+ },
157
+
158
+
159
+ popPortalView: () => {
160
+ const { portalStack } = get();
161
+
162
+ if (portalStack.length <= 1) {
163
+ // Stack empty or only one item, clear stack and close portal
164
+ set({ portalStack: [], showPortal: false }, false, 'popPortalView/close');
165
+ } else {
166
+ set({ portalStack: portalStack.slice(0, -1) }, false, 'popPortalView');
167
+ }
168
+ },
169
+
170
+ // ============== Core Stack Operations ==============
171
+ pushPortalView: (view) => {
172
+ const { portalStack } = get();
173
+ const top = portalStack.at(-1);
174
+
175
+ // If top of stack is same type, replace instead of push (avoid duplicates)
176
+ if (top?.type === view.type) {
177
+ set(
178
+ {
179
+ portalStack: [...portalStack.slice(0, -1), view],
180
+ showPortal: true,
181
+ },
182
+ false,
183
+ 'pushPortalView/replace',
184
+ );
185
+ } else {
186
+ set(
187
+ {
188
+ portalStack: [...portalStack, view],
189
+ showPortal: true,
190
+ },
191
+ false,
192
+ 'pushPortalView',
193
+ );
194
+ }
195
+ },
196
+
197
+ replacePortalView: (view) => {
198
+ const { portalStack } = get();
199
+
200
+ if (portalStack.length === 0) {
201
+ set({ portalStack: [view], showPortal: true }, false, 'replacePortalView/push');
202
+ } else {
203
+ set(
204
+ {
205
+ portalStack: [...portalStack.slice(0, -1), view],
206
+ showPortal: true,
207
+ },
208
+ false,
209
+ 'replacePortalView',
210
+ );
211
+ }
81
212
  },
82
213
 
83
214
  toggleNotebook: (open) => {
84
- const showNotebook = open === undefined ? !get().showNotebook : open;
215
+ const { portalStack } = get();
216
+ const isCurrentlyNotebook = getCurrentViewType(portalStack) === PortalViewType.Notebook;
217
+ const shouldOpen = open ?? !isCurrentlyNotebook;
85
218
 
86
- get().togglePortal(showNotebook);
87
- set({ showNotebook }, false, 'toggleNotebook');
219
+ if (shouldOpen) {
220
+ get().openNotebook();
221
+ } else {
222
+ get().closeNotebook();
223
+ }
88
224
  },
225
+
89
226
  togglePortal: (open) => {
90
- const showInspector = open === undefined ? !get().showPortal : open;
91
- set({ showPortal: showInspector }, false, 'toggleInspector');
227
+ const nextOpen = open === undefined ? !get().showPortal : open;
228
+
229
+ if (!nextOpen) {
230
+ // When closing, clear the stack
231
+ set({ portalStack: [], showPortal: false }, false, 'togglePortal/close');
232
+ } else {
233
+ // When opening, if stack is empty, push Home view
234
+ const { portalStack } = get();
235
+ if (portalStack.length === 0) {
236
+ set(
237
+ {
238
+ portalStack: [{ type: PortalViewType.Home }],
239
+ showPortal: true,
240
+ },
241
+ false,
242
+ 'togglePortal/openHome',
243
+ );
244
+ } else {
245
+ set({ showPortal: true }, false, 'togglePortal/open');
246
+ }
247
+ }
92
248
  },
93
- // updateArtifactContent: (content) => {
94
- // set({ portalArtifact: content }, false, 'updateArtifactContent');
95
- // },
96
249
  });
@@ -5,25 +5,64 @@ export enum ArtifactDisplayMode {
5
5
  Preview = 'preview',
6
6
  }
7
7
 
8
+ // ============== Portal View Stack Types ==============
9
+
10
+ export enum PortalViewType {
11
+ Artifact = 'artifact',
12
+ Document = 'document',
13
+ FilePreview = 'filePreview',
14
+ GroupThread = 'groupThread',
15
+ Home = 'home',
16
+ MessageDetail = 'messageDetail',
17
+ Notebook = 'notebook',
18
+ Thread = 'thread',
19
+ ToolUI = 'toolUI'
20
+ }
21
+
8
22
  export interface PortalFile {
9
23
  chunkId?: string;
10
24
  chunkText?: string;
11
25
  fileId: string;
12
26
  }
13
27
 
28
+ export type PortalViewData =
29
+ | { type: PortalViewType.Home }
30
+ | { artifact: PortalArtifact, type: PortalViewType.Artifact; }
31
+ | { documentId: string, type: PortalViewType.Document; }
32
+ | { type: PortalViewType.Notebook }
33
+ | { file: PortalFile, type: PortalViewType.FilePreview; }
34
+ | { messageId: string, type: PortalViewType.MessageDetail; }
35
+ | { identifier: string, messageId: string; type: PortalViewType.ToolUI; }
36
+ | { startMessageId?: string, threadId?: string; type: PortalViewType.Thread; }
37
+ | { agentId: string, type: PortalViewType.GroupThread; };
38
+
39
+ // ============== Portal State ==============
40
+
14
41
  export interface ChatPortalState {
42
+ // Legacy fields (kept for backward compatibility during migration)
43
+ // TODO: Remove after Phase 3 migration complete
44
+ /** @deprecated Use portalStack instead */
15
45
  portalArtifact?: PortalArtifact;
16
- portalArtifactDisplayMode?: ArtifactDisplayMode;
46
+ portalArtifactDisplayMode: ArtifactDisplayMode;
47
+ /** @deprecated Use portalStack instead */
17
48
  portalDocumentId?: string;
49
+
50
+ /** @deprecated Use portalStack instead */
18
51
  portalFile?: PortalFile;
52
+ /** @deprecated Use portalStack instead */
19
53
  portalMessageDetail?: string;
54
+ portalStack: PortalViewData[];
55
+ /** @deprecated Use portalStack instead */
20
56
  portalThreadId?: string;
57
+ /** @deprecated Use portalStack instead */
21
58
  portalToolMessage?: { id: string; identifier: string };
59
+ /** @deprecated Use portalStack instead */
22
60
  showNotebook?: boolean;
23
61
  showPortal: boolean;
24
62
  }
25
63
 
26
64
  export const initialChatPortalState: ChatPortalState = {
27
65
  portalArtifactDisplayMode: ArtifactDisplayMode.Preview,
66
+ portalStack: [],
28
67
  showPortal: false,
29
68
  };
@@ -1,17 +1,58 @@
1
1
  import type { ChatStoreState } from '@/store/chat';
2
2
 
3
- const showThread = (s: ChatStoreState) => !!s.threadStartMessageId || !!s.portalThreadId;
3
+ import { type PortalViewData, PortalViewType } from '../initialState';
4
+
5
+ // Helper to get current view
6
+ const getCurrentView = (s: ChatStoreState): PortalViewData | null => {
7
+ const { portalStack } = s;
8
+ return portalStack.at(-1) ?? null;
9
+ };
10
+
11
+ // Check if current view is Thread
12
+ const showThread = (s: ChatStoreState) => {
13
+ const view = getCurrentView(s);
14
+ if (view?.type === PortalViewType.Thread) {
15
+ return true;
16
+ }
17
+ // Also check legacy threadStartMessageId for backward compatibility during transition
18
+ return !!s.threadStartMessageId;
19
+ };
4
20
 
5
21
  const newThreadMode = (s: ChatStoreState) => s.newThreadMode;
6
22
 
23
+ // Get current thread data from stack
24
+ const currentThreadView = (s: ChatStoreState) => {
25
+ const view = getCurrentView(s);
26
+ if (view?.type === PortalViewType.Thread) {
27
+ return view;
28
+ }
29
+ return null;
30
+ };
31
+
32
+ // Get thread ID - from stack or legacy field
33
+ const portalThreadId = (s: ChatStoreState): string | undefined => {
34
+ const threadView = currentThreadView(s);
35
+ return threadView?.threadId ?? s.portalThreadId;
36
+ };
37
+
38
+ // Get start message ID - from stack or legacy field
39
+ const threadStartMessageId = (s: ChatStoreState): string | undefined => {
40
+ const threadView = currentThreadView(s);
41
+ return threadView?.startMessageId ?? s.threadStartMessageId ?? undefined;
42
+ };
43
+
7
44
  const portalCurrentThread = (s: ChatStoreState) => {
8
- if (!s.portalThreadId || !s.activeTopicId) return;
45
+ const threadId = portalThreadId(s);
46
+ if (!threadId || !s.activeTopicId) return;
9
47
 
10
- return (s.threadMaps[s.activeTopicId] || []).find((t) => t.id === s.portalThreadId);
48
+ return (s.threadMaps[s.activeTopicId] || []).find((t) => t.id === threadId);
11
49
  };
12
50
 
13
51
  export const portalThreadSelectors = {
52
+ currentThreadView,
14
53
  newThreadMode,
15
54
  portalCurrentThread,
55
+ portalThreadId,
16
56
  showThread,
57
+ threadStartMessageId,
17
58
  };