@nocobase/portal-template-default 1.0.0 → 1.0.2

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 (194) hide show
  1. package/README.MD +90 -1
  2. package/components.json +3 -1
  3. package/package.json +21 -5
  4. package/registry/nocobase-acl/README.md +19 -0
  5. package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
  6. package/registry/nocobase-acl/components/index.ts +3 -0
  7. package/registry/nocobase-acl/components/role-options.ts +36 -0
  8. package/registry/nocobase-acl/components/role-switcher.tsx +135 -0
  9. package/registry/nocobase-acl/demo/boundary-api.tsx +186 -0
  10. package/registry/nocobase-acl/demo/components.tsx +231 -0
  11. package/registry/nocobase-acl/demo/index.tsx +505 -0
  12. package/registry/nocobase-acl/demo/lazy-route.tsx +10 -0
  13. package/registry/nocobase-acl/demo/prompt-generator.tsx +134 -0
  14. package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
  15. package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
  16. package/registry/nocobase-acl/extension.tsx +71 -0
  17. package/registry/nocobase-acl/index.ts +1 -0
  18. package/registry/nocobase-acl/tests/acl-regression.mjs +314 -0
  19. package/registry/nocobase-ai/README.md +70 -0
  20. package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
  21. package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
  22. package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
  23. package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
  24. package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
  25. package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
  26. package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
  27. package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
  28. package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
  29. package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
  30. package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
  31. package/registry/nocobase-ai/components/chat/chat-messages.tsx +173 -0
  32. package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
  33. package/registry/nocobase-ai/components/chat/conversation-list.tsx +343 -0
  34. package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
  35. package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
  36. package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
  37. package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
  38. package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
  39. package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
  40. package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
  41. package/registry/nocobase-ai/components/index.ts +59 -0
  42. package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
  43. package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
  44. package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
  45. package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
  46. package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
  47. package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
  48. package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
  49. package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
  50. package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
  51. package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
  52. package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
  53. package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
  54. package/registry/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
  55. package/registry/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
  56. package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
  57. package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
  58. package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
  59. package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
  60. package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
  61. package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
  62. package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
  63. package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
  64. package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
  65. package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
  66. package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
  67. package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
  68. package/registry/nocobase-ai/demo/configuration-gate.tsx +49 -0
  69. package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
  70. package/registry/nocobase-ai/demo/floating.tsx +134 -0
  71. package/registry/nocobase-ai/demo/index.tsx +346 -0
  72. package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
  73. package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
  74. package/registry/nocobase-ai/demo/page-context.tsx +884 -0
  75. package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
  76. package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
  77. package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
  78. package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
  79. package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
  80. package/registry/nocobase-ai/extension.tsx +158 -0
  81. package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
  82. package/registry/nocobase-ai/index.ts +3 -0
  83. package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
  84. package/registry/nocobase-ai/providers/avatars.ts +817 -0
  85. package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
  86. package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
  87. package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
  88. package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
  89. package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
  90. package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
  91. package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
  92. package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
  93. package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
  94. package/registry/nocobase-ai/providers/index.ts +61 -0
  95. package/registry/nocobase-ai/providers/model.ts +32 -0
  96. package/registry/nocobase-ai/providers/page-context.tsx +136 -0
  97. package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
  98. package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
  99. package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
  100. package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
  101. package/registry/nocobase-ai/providers/types.ts +183 -0
  102. package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
  103. package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
  104. package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
  105. package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
  106. package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
  107. package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
  108. package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
  109. package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
  110. package/registry/nocobase-ai/services/index.ts +7 -0
  111. package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
  112. package/registry/nocobase-ai/services/types.ts +82 -0
  113. package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
  114. package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
  115. package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
  116. package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
  117. package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
  118. package/registry/nocobase-i18n/README.md +53 -0
  119. package/registry/nocobase-i18n/components/index.ts +1 -0
  120. package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
  121. package/registry/nocobase-i18n/demo/index.tsx +209 -0
  122. package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
  123. package/registry/nocobase-i18n/extension.tsx +46 -0
  124. package/registry/nocobase-i18n/i18n-provider.ts +9 -0
  125. package/registry/nocobase-i18n/index.ts +6 -0
  126. package/registry/nocobase-i18n/locale-store.ts +134 -0
  127. package/registry/nocobase-i18n/locales/en-US.ts +89 -0
  128. package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
  129. package/registry/nocobase-i18n/provider.tsx +32 -0
  130. package/registry/nocobase-i18n/runtime.ts +118 -0
  131. package/registry/nocobase-i18n/server-resources.ts +184 -0
  132. package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
  133. package/registry.config.json +163 -0
  134. package/scripts/registry.mjs +164 -0
  135. package/src/App.css +95 -0
  136. package/src/App.tsx +87 -69
  137. package/src/app/extension.ts +11 -0
  138. package/src/app/extensions.tsx +49 -0
  139. package/src/components/access-control/access-denied.tsx +30 -0
  140. package/src/components/access-control/acl-bootstrap.tsx +9 -0
  141. package/src/components/access-control/acl-gate.tsx +36 -0
  142. package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
  143. package/src/components/access-control/resource-access-guard.tsx +21 -0
  144. package/src/components/access-control/use-acl-runtime.ts +28 -0
  145. package/src/components/app-shell/breadcrumb.tsx +39 -3
  146. package/src/components/app-shell/document-title-handler.tsx +11 -20
  147. package/src/components/app-shell/header.tsx +22 -4
  148. package/src/components/app-shell/loading-overlay.tsx +2 -4
  149. package/src/components/app-shell/loading-state.tsx +15 -0
  150. package/src/components/app-shell/sidebar.tsx +103 -45
  151. package/src/components/data-table/data-table-pagination.tsx +27 -7
  152. package/src/components/data-table/data-table-styles.ts +57 -0
  153. package/src/components/data-table/data-table.tsx +14 -59
  154. package/src/components/resources/resource-label.ts +101 -0
  155. package/src/components/resources/views/create-view.tsx +17 -14
  156. package/src/components/resources/views/edit-view.tsx +17 -15
  157. package/src/components/resources/views/list-view.tsx +20 -10
  158. package/src/components/resources/views/show-view.tsx +20 -18
  159. package/src/index.tsx +2 -3
  160. package/src/lib/i18n.ts +161 -0
  161. package/src/lib/nocobase/acl/action.ts +147 -0
  162. package/src/lib/nocobase/acl/data-source.ts +24 -0
  163. package/src/lib/nocobase/acl/index.ts +6 -0
  164. package/src/lib/nocobase/acl/menu.ts +41 -0
  165. package/src/lib/nocobase/acl/record-permissions.ts +111 -0
  166. package/src/lib/nocobase/acl/store.ts +182 -0
  167. package/src/lib/nocobase/acl/types.ts +96 -0
  168. package/src/lib/nocobase/client.ts +279 -0
  169. package/src/lib/nocobase/error.ts +32 -0
  170. package/src/locales/en-US.ts +82 -0
  171. package/src/locales/index.ts +13 -0
  172. package/src/locales/zh-CN.ts +79 -0
  173. package/src/pages/users/create.tsx +98 -0
  174. package/src/pages/users/edit.tsx +92 -0
  175. package/src/pages/users/form-context.ts +57 -0
  176. package/src/pages/users/form-fields.tsx +211 -0
  177. package/src/pages/users/list.tsx +205 -0
  178. package/src/pages/users/show.tsx +187 -0
  179. package/src/pages/users/types.ts +20 -0
  180. package/src/providers/access-control.ts +34 -0
  181. package/src/providers/auth.ts +74 -60
  182. package/src/providers/constants.ts +3 -37
  183. package/src/providers/data.ts +242 -85
  184. package/vite.config.ts +17 -0
  185. package/src/pages/blog-posts/create.tsx +0 -177
  186. package/src/pages/blog-posts/edit.tsx +0 -197
  187. package/src/pages/blog-posts/list.tsx +0 -131
  188. package/src/pages/blog-posts/show.tsx +0 -81
  189. package/src/pages/categories/create.tsx +0 -73
  190. package/src/pages/categories/edit.tsx +0 -73
  191. package/src/pages/categories/index.ts +0 -4
  192. package/src/pages/categories/list.tsx +0 -86
  193. package/src/pages/categories/show.tsx +0 -37
  194. /package/src/pages/{blog-posts → users}/index.ts +0 -0
@@ -0,0 +1,252 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import {
3
+ type AIChatMessage as AIChatMessageType,
4
+ type AIToolCallDecision,
5
+ } from "../../providers";
6
+ import { Check, Copy, Pencil, RefreshCcw } from "lucide-react";
7
+ import { memo, useState } from "react";
8
+ import { MarkdownMessage } from "./markdown-message";
9
+ import { ReasoningPanel } from "./reasoning-panel";
10
+ import {
11
+ getToolCallName,
12
+ isToolCallPart,
13
+ ToolCallCard,
14
+ } from "./tool-call-card";
15
+ import { ChatAttachment } from "./chat-attachment";
16
+ import { useAIToolRenderer } from "../tools/tool-renderer-provider";
17
+ import { SubAgentConversation } from "./sub-agent-conversation";
18
+ import { WorkContextChip } from "./work-context-chip";
19
+
20
+ type ChatMessageProps = {
21
+ message: AIChatMessageType;
22
+ onToolCallDecision?: (decision: AIToolCallDecision) => void | Promise<void>;
23
+ showActions?: boolean;
24
+ status?: "submitted" | "streaming" | "ready" | "error";
25
+ retryMessage?: (message: AIChatMessageType) => Promise<void>;
26
+ decideToolCall?: (decision: AIToolCallDecision) => Promise<void>;
27
+ startEditingMessage?: (message: AIChatMessageType) => Promise<void>;
28
+ focusComposer?: () => void;
29
+ };
30
+
31
+ function ChatMessageComponent({
32
+ message,
33
+ onToolCallDecision,
34
+ showActions = true,
35
+ status = "ready",
36
+ retryMessage,
37
+ decideToolCall,
38
+ startEditingMessage,
39
+ focusComposer,
40
+ }: ChatMessageProps) {
41
+ const interactionPending = status === "streaming" || status === "submitted";
42
+ const [copied, setCopied] = useState(false);
43
+ const isUser = message.role === "user";
44
+ const text = message.parts
45
+ .filter((part) => part.type === "text")
46
+ .map((part) => part.text)
47
+ .join("\n");
48
+ const toolCalls = message.parts.filter(isToolCallPart);
49
+ const singleToolCall = toolCalls.length === 1 ? toolCalls[0] : undefined;
50
+ const singleToolRenderer = useAIToolRenderer(
51
+ singleToolCall ? getToolCallName(singleToolCall) : ""
52
+ );
53
+ const useInlineToolActions =
54
+ !text && Boolean(singleToolCall) && !singleToolRenderer;
55
+ const assistantParts = message.parts.filter(
56
+ (part) =>
57
+ part.type === "text" ||
58
+ part.type === "reasoning" ||
59
+ part.type === "data-subAgent" ||
60
+ isToolCallPart(part)
61
+ );
62
+ const showGenerating = !assistantParts.length && interactionPending;
63
+ const attachments = message.metadata?.attachments ?? [];
64
+ const workContext = message.metadata?.workContext ?? [];
65
+ if (isUser) {
66
+ return (
67
+ <article className="group/message flex flex-col items-end px-4 py-2 sm:px-5">
68
+ <div className="flex max-w-[80%] flex-col items-end">
69
+ {workContext.length ? (
70
+ <div className="mb-1.5 flex max-w-full flex-wrap justify-end gap-1.5">
71
+ {workContext.map((item, index) => (
72
+ <WorkContextChip
73
+ key={`${item.type}:${item.id ?? item.title ?? index}`}
74
+ item={item}
75
+ className="bg-background"
76
+ />
77
+ ))}
78
+ </div>
79
+ ) : null}
80
+ {attachments.length ? (
81
+ <div className="mb-1.5 flex max-w-full flex-wrap justify-end gap-1.5">
82
+ {attachments.map((attachment) => (
83
+ <ChatAttachment key={attachment.uid} attachment={attachment} />
84
+ ))}
85
+ </div>
86
+ ) : null}
87
+ {text ? (
88
+ <div className="rounded-2xl rounded-br-md bg-secondary px-4 py-2.5 text-sm leading-6 text-secondary-foreground">
89
+ <div className="whitespace-pre-wrap break-words">{text}</div>
90
+ </div>
91
+ ) : null}
92
+ </div>
93
+ {showActions ? (
94
+ <div className="pointer-events-none mt-1 flex h-6 items-center gap-1 opacity-0 transition-opacity group-hover/message:pointer-events-auto group-hover/message:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
95
+ <Button
96
+ variant="ghost"
97
+ size="icon-xs"
98
+ aria-label="Edit message"
99
+ disabled={interactionPending || !startEditingMessage}
100
+ onClick={() => void startEditingMessage?.(message)}
101
+ >
102
+ <Pencil />
103
+ </Button>
104
+ {text ? (
105
+ <Button
106
+ variant="ghost"
107
+ size="icon-xs"
108
+ aria-label="Copy message"
109
+ onClick={async () => {
110
+ await navigator.clipboard.writeText(text);
111
+ setCopied(true);
112
+ window.setTimeout(() => setCopied(false), 1200);
113
+ }}
114
+ >
115
+ {copied ? <Check /> : <Copy />}
116
+ </Button>
117
+ ) : null}
118
+ </div>
119
+ ) : null}
120
+ </article>
121
+ );
122
+ }
123
+
124
+ const messageActions = (
125
+ <>
126
+ {text ? (
127
+ <Button
128
+ variant="ghost"
129
+ size="icon-xs"
130
+ aria-label="Copy response"
131
+ onClick={async () => {
132
+ await navigator.clipboard.writeText(text);
133
+ setCopied(true);
134
+ window.setTimeout(() => setCopied(false), 1200);
135
+ }}
136
+ >
137
+ {copied ? <Check /> : <Copy />}
138
+ </Button>
139
+ ) : null}
140
+ <Button
141
+ variant="ghost"
142
+ size="icon-xs"
143
+ aria-label="Retry response"
144
+ disabled={interactionPending || !retryMessage}
145
+ onClick={() => void retryMessage?.(message)}
146
+ >
147
+ <RefreshCcw />
148
+ </Button>
149
+ </>
150
+ );
151
+
152
+ return (
153
+ <article className="group/message px-4 py-2 sm:px-5">
154
+ <div className="min-w-0 space-y-4">
155
+ {assistantParts.map((part, index) => {
156
+ if (part.type === "reasoning") {
157
+ return (
158
+ <ReasoningPanel
159
+ key={`reasoning-${index}`}
160
+ streaming={part.state === "streaming"}
161
+ >
162
+ {part.text}
163
+ </ReasoningPanel>
164
+ );
165
+ }
166
+ if (part.type === "text") {
167
+ return (
168
+ <div
169
+ key={`text-${index}`}
170
+ className="ai-markdown text-sm leading-6 text-foreground"
171
+ >
172
+ <MarkdownMessage>{part.text}</MarkdownMessage>
173
+ </div>
174
+ );
175
+ }
176
+ if (part.type === "data-subAgent") {
177
+ return (
178
+ <SubAgentConversation
179
+ key={part.id ?? part.data.sessionId}
180
+ conversation={part.data}
181
+ onToolCallDecision={onToolCallDecision}
182
+ status={status}
183
+ decideToolCall={decideToolCall}
184
+ focusComposer={focusComposer}
185
+ />
186
+ );
187
+ }
188
+ if (!isToolCallPart(part)) return null;
189
+ return (
190
+ <ToolCallCard
191
+ key={part.toolCallId}
192
+ part={part}
193
+ approval={message.metadata?.toolApprovals?.[part.toolCallId]}
194
+ disabled={interactionPending}
195
+ onRevise={focusComposer}
196
+ inlineActions={
197
+ showActions && useInlineToolActions && part === singleToolCall
198
+ ? messageActions
199
+ : undefined
200
+ }
201
+ onDecision={async (decision, input) => {
202
+ const toolDecision = {
203
+ messageId: message.id,
204
+ toolCallId: part.toolCallId,
205
+ toolName: getToolCallName(part),
206
+ decision,
207
+ input,
208
+ } satisfies AIToolCallDecision;
209
+ await decideToolCall?.(toolDecision);
210
+ try {
211
+ await onToolCallDecision?.(toolDecision);
212
+ } catch (error) {
213
+ console.error("Tool-call decision callback failed", error);
214
+ }
215
+ }}
216
+ />
217
+ );
218
+ })}
219
+ {showGenerating ? (
220
+ <div className="min-h-6 text-sm leading-6 text-foreground">
221
+ <span
222
+ className="inline-flex gap-1 py-2"
223
+ aria-label="Generating response"
224
+ >
225
+ <span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/70" />
226
+ <span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/70 [animation-delay:120ms]" />
227
+ <span className="size-1.5 animate-pulse rounded-full bg-muted-foreground/70 [animation-delay:240ms]" />
228
+ </span>
229
+ </div>
230
+ ) : null}
231
+ </div>
232
+ {showActions && (text || (toolCalls.length && !useInlineToolActions)) ? (
233
+ <div className="pointer-events-none mt-1 flex h-6 items-center gap-1 opacity-0 transition-opacity group-hover/message:pointer-events-auto group-hover/message:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
234
+ {messageActions}
235
+ </div>
236
+ ) : null}
237
+ </article>
238
+ );
239
+ }
240
+
241
+ export const ChatMessage = memo(
242
+ ChatMessageComponent,
243
+ (previous, next) =>
244
+ previous.message === next.message &&
245
+ previous.status === next.status &&
246
+ previous.showActions === next.showActions &&
247
+ previous.onToolCallDecision === next.onToolCallDecision &&
248
+ previous.retryMessage === next.retryMessage &&
249
+ previous.decideToolCall === next.decideToolCall &&
250
+ previous.startEditingMessage === next.startEditingMessage &&
251
+ previous.focusComposer === next.focusComposer
252
+ );
@@ -0,0 +1,173 @@
1
+ import { Button } from "@/components/ui/button";
2
+ import { LoadingState } from "@/components/app-shell/loading-state";
3
+ import {
4
+ useAIChatBase,
5
+ useAIChatMessages,
6
+ useAIChatStatus,
7
+ type AIChatMessage,
8
+ type AIToolCallDecision,
9
+ } from "../../providers";
10
+ import { cn } from "@/lib/utils";
11
+ import { ArrowDown } from "lucide-react";
12
+ import { useEffect, useRef, useState, type ReactNode } from "react";
13
+ import { ChatEmptyState } from "./chat-empty-state";
14
+ import { ChatMessage } from "./chat-message";
15
+
16
+ export function ChatMessages({
17
+ onToolCallDecision,
18
+ }: {
19
+ onToolCallDecision?: (decision: AIToolCallDecision) => void | Promise<void>;
20
+ }) {
21
+ const { messages } = useAIChatMessages();
22
+ const { status, error } = useAIChatStatus();
23
+ const {
24
+ messagesLoading,
25
+ historyError,
26
+ interactionError,
27
+ retryMessage,
28
+ decideToolCall,
29
+ startEditingMessage,
30
+ focusComposer,
31
+ } = useAIChatBase();
32
+
33
+ return (
34
+ <AIChatMessageList
35
+ messages={messages}
36
+ status={status}
37
+ loading={messagesLoading}
38
+ error={error}
39
+ historyError={historyError}
40
+ interactionError={interactionError}
41
+ onToolCallDecision={onToolCallDecision}
42
+ retryMessage={retryMessage}
43
+ decideToolCall={decideToolCall}
44
+ startEditingMessage={startEditingMessage}
45
+ focusComposer={focusComposer}
46
+ />
47
+ );
48
+ }
49
+
50
+ export type AIChatMessageListProps = {
51
+ messages: AIChatMessage[];
52
+ status?: "submitted" | "streaming" | "ready" | "error";
53
+ loading?: boolean;
54
+ error?: Error | null;
55
+ historyError?: Error | null;
56
+ interactionError?: Error | null;
57
+ emptyState?: ReactNode;
58
+ className?: string;
59
+ onToolCallDecision?: (decision: AIToolCallDecision) => void | Promise<void>;
60
+ showMessageActions?: boolean;
61
+ retryMessage?: (message: AIChatMessage) => Promise<void>;
62
+ decideToolCall?: (decision: AIToolCallDecision) => Promise<void>;
63
+ startEditingMessage?: (message: AIChatMessage) => Promise<void>;
64
+ focusComposer?: () => void;
65
+ };
66
+
67
+ export function AIChatMessageList({
68
+ messages,
69
+ status = "ready",
70
+ loading = false,
71
+ error,
72
+ historyError,
73
+ interactionError,
74
+ emptyState,
75
+ className,
76
+ onToolCallDecision,
77
+ showMessageActions = true,
78
+ retryMessage,
79
+ decideToolCall,
80
+ startEditingMessage,
81
+ focusComposer,
82
+ }: AIChatMessageListProps) {
83
+ const viewportRef = useRef<HTMLDivElement>(null);
84
+ const [atBottom, setAtBottom] = useState(true);
85
+
86
+ const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
87
+ const viewport = viewportRef.current;
88
+ if (!viewport) return;
89
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior });
90
+ };
91
+
92
+ useEffect(() => {
93
+ if (atBottom) scrollToBottom(status === "streaming" ? "auto" : "smooth");
94
+ }, [atBottom, messages, status]);
95
+
96
+ return (
97
+ <div className={cn("relative min-h-0 flex-1 bg-background", className)}>
98
+ <div
99
+ ref={viewportRef}
100
+ role="log"
101
+ aria-live="polite"
102
+ className="absolute inset-0 overflow-y-auto overscroll-contain"
103
+ onScroll={(event) => {
104
+ const element = event.currentTarget;
105
+ setAtBottom(
106
+ element.scrollHeight - element.scrollTop - element.clientHeight < 48
107
+ );
108
+ }}
109
+ >
110
+ {loading ? (
111
+ <LoadingState className="h-full" />
112
+ ) : (
113
+ <>
114
+ {messages.length ? (
115
+ <div className="mx-auto w-full max-w-3xl py-2">
116
+ {messages.map((message) => (
117
+ <ChatMessage
118
+ key={message.id}
119
+ message={message}
120
+ onToolCallDecision={onToolCallDecision}
121
+ showActions={showMessageActions}
122
+ status={status}
123
+ retryMessage={retryMessage}
124
+ decideToolCall={decideToolCall}
125
+ startEditingMessage={startEditingMessage}
126
+ focusComposer={focusComposer}
127
+ />
128
+ ))}
129
+ </div>
130
+ ) : (
131
+ emptyState ?? <ChatEmptyState />
132
+ )}
133
+ {error ? (
134
+ <div
135
+ role="alert"
136
+ className="mx-5 my-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm text-destructive"
137
+ >
138
+ {error.message}
139
+ </div>
140
+ ) : null}
141
+ {historyError ? (
142
+ <div
143
+ role="alert"
144
+ className="mx-5 my-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm text-destructive"
145
+ >
146
+ {historyError.message}
147
+ </div>
148
+ ) : null}
149
+ {interactionError ? (
150
+ <div
151
+ role="alert"
152
+ className="mx-5 my-3 rounded-lg border border-destructive/25 bg-destructive/5 px-3 py-2 text-sm text-destructive"
153
+ >
154
+ {interactionError.message}
155
+ </div>
156
+ ) : null}
157
+ </>
158
+ )}
159
+ </div>
160
+ {!atBottom ? (
161
+ <Button
162
+ size="icon-sm"
163
+ variant="outline"
164
+ className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full bg-background shadow-sm"
165
+ aria-label="Scroll to bottom"
166
+ onClick={() => scrollToBottom()}
167
+ >
168
+ <ArrowDown />
169
+ </Button>
170
+ ) : null}
171
+ </div>
172
+ );
173
+ }
@@ -0,0 +1,118 @@
1
+ import { cn } from "@/lib/utils";
2
+ import { useAIChatBase, type AIToolCallDecision } from "../../providers";
3
+ import { useRef, useState, type DragEvent, type ReactNode } from "react";
4
+ import { ChatComposer, type AIChatComposerAction } from "./chat-composer";
5
+ import { ChatHeader } from "./chat-header";
6
+ import { ChatMessages } from "./chat-messages";
7
+ import { ConversationList } from "./conversation-list";
8
+
9
+ export function AIChatWindow({
10
+ className,
11
+ headerActions,
12
+ composerActions,
13
+ showConversationToggle = true,
14
+ showNewConversation = true,
15
+ showEmployeeSelector = true,
16
+ showModelSelector = true,
17
+ showUserPrompt = true,
18
+ enableAttachments = false,
19
+ attachmentActionIndex = 0,
20
+ placeholder,
21
+ disclaimer,
22
+ onToolCallDecision,
23
+ }: AIChatWindowProps) {
24
+ const { conversationListOpen, setConversationListOpen, uploadFiles } =
25
+ useAIChatBase();
26
+ const [draggingFiles, setDraggingFiles] = useState(false);
27
+ const dragDepthRef = useRef(0);
28
+
29
+ const hasFiles = (event: DragEvent) =>
30
+ Array.from(event.dataTransfer.types).includes("Files");
31
+
32
+ return (
33
+ <section
34
+ className={cn(
35
+ "ai-chat-window relative flex h-full min-h-0 w-full overflow-hidden bg-background text-foreground",
36
+ className
37
+ )}
38
+ onDragEnter={(event) => {
39
+ if (!enableAttachments || !hasFiles(event)) return;
40
+ event.preventDefault();
41
+ dragDepthRef.current += 1;
42
+ setDraggingFiles(true);
43
+ }}
44
+ onDragOver={(event) => {
45
+ if (!enableAttachments || !hasFiles(event)) return;
46
+ event.preventDefault();
47
+ event.dataTransfer.dropEffect = "copy";
48
+ }}
49
+ onDragLeave={(event) => {
50
+ if (!enableAttachments || !hasFiles(event)) return;
51
+ event.preventDefault();
52
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
53
+ if (dragDepthRef.current === 0) setDraggingFiles(false);
54
+ }}
55
+ onDrop={(event) => {
56
+ if (!enableAttachments || !hasFiles(event)) return;
57
+ event.preventDefault();
58
+ dragDepthRef.current = 0;
59
+ setDraggingFiles(false);
60
+ const files = Array.from(event.dataTransfer.files);
61
+ if (files.length) void uploadFiles(files);
62
+ }}
63
+ >
64
+ {draggingFiles ? (
65
+ <div className="pointer-events-none absolute inset-2 z-50 flex items-center justify-center rounded-xl border-2 border-dashed border-foreground/25 bg-background/90 text-sm font-medium shadow-sm backdrop-blur-sm">
66
+ Drop files to upload
67
+ </div>
68
+ ) : null}
69
+ {conversationListOpen && showConversationToggle ? (
70
+ <button
71
+ type="button"
72
+ className="ai-chat-conversation-backdrop absolute inset-0 z-20 bg-black/25 backdrop-blur-[1px]"
73
+ aria-label="Close conversation list"
74
+ onClick={() => setConversationListOpen(false)}
75
+ />
76
+ ) : null}
77
+ {conversationListOpen && showConversationToggle ? (
78
+ <aside className="ai-chat-conversation-panel z-30 flex h-full w-[300px] shrink-0 border-r bg-card">
79
+ <ConversationList />
80
+ </aside>
81
+ ) : null}
82
+ <div className="flex min-w-0 flex-1 flex-col">
83
+ <ChatHeader
84
+ actions={headerActions}
85
+ showConversationToggle={showConversationToggle}
86
+ showNewConversation={showNewConversation}
87
+ showUserPrompt={showUserPrompt}
88
+ />
89
+ <ChatMessages onToolCallDecision={onToolCallDecision} />
90
+ <ChatComposer
91
+ actions={composerActions}
92
+ showEmployeeSelector={showEmployeeSelector}
93
+ showModelSelector={showModelSelector}
94
+ enableAttachments={enableAttachments}
95
+ attachmentActionIndex={attachmentActionIndex}
96
+ placeholder={placeholder}
97
+ disclaimer={disclaimer}
98
+ />
99
+ </div>
100
+ </section>
101
+ );
102
+ }
103
+
104
+ export type AIChatWindowProps = {
105
+ className?: string;
106
+ headerActions?: ReactNode;
107
+ composerActions?: AIChatComposerAction[];
108
+ showConversationToggle?: boolean;
109
+ showNewConversation?: boolean;
110
+ showEmployeeSelector?: boolean;
111
+ showModelSelector?: boolean;
112
+ showUserPrompt?: boolean;
113
+ enableAttachments?: boolean;
114
+ attachmentActionIndex?: number;
115
+ placeholder?: string;
116
+ disclaimer?: ReactNode | false;
117
+ onToolCallDecision?: (decision: AIToolCallDecision) => void | Promise<void>;
118
+ };