@tangle-network/ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -0
  4. package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
  5. package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
  6. package/dist/auth.d.ts +74 -0
  7. package/dist/auth.js +15 -0
  8. package/dist/button-CMQuQEW_.d.ts +17 -0
  9. package/dist/chat.d.ts +232 -0
  10. package/dist/chat.js +30 -0
  11. package/dist/chunk-2NFQRQOD.js +1009 -0
  12. package/dist/chunk-2VH6PUXD.js +186 -0
  13. package/dist/chunk-34A66VBG.js +214 -0
  14. package/dist/chunk-3OI2QKFD.js +0 -0
  15. package/dist/chunk-4CLN43XT.js +45 -0
  16. package/dist/chunk-54SQQMMM.js +156 -0
  17. package/dist/chunk-5Z5ZYMOJ.js +0 -0
  18. package/dist/chunk-66BNMOVT.js +167 -0
  19. package/dist/chunk-6BGQA4BQ.js +0 -0
  20. package/dist/chunk-7UO2ZMRQ.js +133 -0
  21. package/dist/chunk-BX6AQMUS.js +183 -0
  22. package/dist/chunk-CD53GZOM.js +59 -0
  23. package/dist/chunk-CSAIKY36.js +54 -0
  24. package/dist/chunk-EEE55AVS.js +1201 -0
  25. package/dist/chunk-GYPQXTJU.js +230 -0
  26. package/dist/chunk-HFL6R6IF.js +37 -0
  27. package/dist/chunk-HJKCSXCH.js +737 -0
  28. package/dist/chunk-LISXUB4D.js +1222 -0
  29. package/dist/chunk-LQS34IGP.js +0 -0
  30. package/dist/chunk-MKTSMWVD.js +109 -0
  31. package/dist/chunk-NKDZ7GZE.js +192 -0
  32. package/dist/chunk-OEX7NZE3.js +321 -0
  33. package/dist/chunk-Q56BYXQF.js +61 -0
  34. package/dist/chunk-Q7EIIWTC.js +0 -0
  35. package/dist/chunk-REJESC5U.js +117 -0
  36. package/dist/chunk-RQGKSCEZ.js +0 -0
  37. package/dist/chunk-RQHJBTEU.js +10 -0
  38. package/dist/chunk-TMFOPHHN.js +299 -0
  39. package/dist/chunk-XGKULLYE.js +40 -0
  40. package/dist/chunk-XIHMJ7ZQ.js +614 -0
  41. package/dist/chunk-YJ2G3XO5.js +1048 -0
  42. package/dist/chunk-YNN4O57I.js +754 -0
  43. package/dist/code-block-DjXf8eOG.d.ts +19 -0
  44. package/dist/document-editor-pane-A5LT5H4N.js +12 -0
  45. package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
  46. package/dist/editor.d.ts +120 -0
  47. package/dist/editor.js +34 -0
  48. package/dist/files.d.ts +175 -0
  49. package/dist/files.js +20 -0
  50. package/dist/hooks.d.ts +56 -0
  51. package/dist/hooks.js +41 -0
  52. package/dist/index.d.ts +43 -0
  53. package/dist/index.js +446 -0
  54. package/dist/markdown.d.ts +15 -0
  55. package/dist/markdown.js +14 -0
  56. package/dist/message-BHWbxBtT.d.ts +15 -0
  57. package/dist/openui.d.ts +115 -0
  58. package/dist/openui.js +12 -0
  59. package/dist/parts-dj7AcUg0.d.ts +36 -0
  60. package/dist/primitives.d.ts +332 -0
  61. package/dist/primitives.js +191 -0
  62. package/dist/run-PfLmDAox.d.ts +41 -0
  63. package/dist/run.d.ts +69 -0
  64. package/dist/run.js +36 -0
  65. package/dist/sdk-hooks.d.ts +285 -0
  66. package/dist/sdk-hooks.js +31 -0
  67. package/dist/stores.d.ts +17 -0
  68. package/dist/stores.js +76 -0
  69. package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
  70. package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
  71. package/dist/tool-previews.d.ts +48 -0
  72. package/dist/tool-previews.js +21 -0
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.js +1 -0
  75. package/dist/utils.d.ts +45 -0
  76. package/dist/utils.js +32 -0
  77. package/package.json +193 -0
  78. package/src/auth/auth.tsx +228 -0
  79. package/src/auth/index.ts +13 -0
  80. package/src/auth/login-layout.tsx +46 -0
  81. package/src/chat/agent-timeline.stories.tsx +429 -0
  82. package/src/chat/agent-timeline.tsx +360 -0
  83. package/src/chat/chat-container.tsx +486 -0
  84. package/src/chat/chat-input.stories.tsx +142 -0
  85. package/src/chat/chat-input.tsx +389 -0
  86. package/src/chat/chat-message.stories.tsx +237 -0
  87. package/src/chat/chat-message.tsx +129 -0
  88. package/src/chat/index.ts +18 -0
  89. package/src/chat/message-list.stories.tsx +336 -0
  90. package/src/chat/message-list.tsx +79 -0
  91. package/src/chat/thinking-indicator.stories.tsx +56 -0
  92. package/src/chat/thinking-indicator.tsx +30 -0
  93. package/src/chat/user-message.stories.tsx +92 -0
  94. package/src/chat/user-message.tsx +43 -0
  95. package/src/editor/document-editor-pane.tsx +351 -0
  96. package/src/editor/editor-provider.tsx +428 -0
  97. package/src/editor/editor-toolbar.tsx +130 -0
  98. package/src/editor/index.ts +31 -0
  99. package/src/editor/markdown-conversion.ts +21 -0
  100. package/src/editor/markdown-document-editor.tsx +137 -0
  101. package/src/editor/tiptap-editor.tsx +331 -0
  102. package/src/editor/use-editor.ts +221 -0
  103. package/src/files/file-artifact-pane.tsx +183 -0
  104. package/src/files/file-preview.tsx +342 -0
  105. package/src/files/file-tabs.tsx +71 -0
  106. package/src/files/file-tree.tsx +258 -0
  107. package/src/files/index.ts +17 -0
  108. package/src/files/rich-file-tree.stories.tsx +104 -0
  109. package/src/files/rich-file-tree.test.tsx +42 -0
  110. package/src/files/rich-file-tree.tsx +232 -0
  111. package/src/hooks/index.ts +10 -0
  112. package/src/hooks/use-auth.ts +153 -0
  113. package/src/hooks/use-auto-scroll.ts +59 -0
  114. package/src/hooks/use-dropdown-menu.ts +40 -0
  115. package/src/hooks/use-live-time.test.tsx +40 -0
  116. package/src/hooks/use-live-time.ts +27 -0
  117. package/src/hooks/use-realtime-session.ts +319 -0
  118. package/src/hooks/use-run-collapse-state.ts +25 -0
  119. package/src/hooks/use-run-groups.ts +111 -0
  120. package/src/hooks/use-sdk-session.ts +575 -0
  121. package/src/hooks/use-sse-stream.ts +475 -0
  122. package/src/hooks/use-tool-call-stream.ts +96 -0
  123. package/src/index.ts +14 -0
  124. package/src/lib/utils.ts +6 -0
  125. package/src/markdown/code-block.tsx +198 -0
  126. package/src/markdown/index.ts +2 -0
  127. package/src/markdown/markdown.stories.tsx +190 -0
  128. package/src/markdown/markdown.tsx +62 -0
  129. package/src/openui/index.ts +20 -0
  130. package/src/openui/openui-artifact-renderer.tsx +542 -0
  131. package/src/primitives/artifact-pane.tsx +91 -0
  132. package/src/primitives/avatar.stories.tsx +95 -0
  133. package/src/primitives/avatar.tsx +47 -0
  134. package/src/primitives/badge.stories.tsx +57 -0
  135. package/src/primitives/badge.tsx +97 -0
  136. package/src/primitives/button.stories.tsx +48 -0
  137. package/src/primitives/button.tsx +115 -0
  138. package/src/primitives/card.stories.tsx +53 -0
  139. package/src/primitives/card.tsx +98 -0
  140. package/src/primitives/code-block.stories.tsx +115 -0
  141. package/src/primitives/code-block.tsx +22 -0
  142. package/src/primitives/design-tokens.stories.tsx +162 -0
  143. package/src/primitives/dialog.stories.tsx +176 -0
  144. package/src/primitives/dialog.tsx +137 -0
  145. package/src/primitives/drop-zone.stories.tsx +123 -0
  146. package/src/primitives/drop-zone.tsx +131 -0
  147. package/src/primitives/dropdown-menu.stories.tsx +122 -0
  148. package/src/primitives/dropdown-menu.tsx +214 -0
  149. package/src/primitives/empty-state.stories.tsx +81 -0
  150. package/src/primitives/empty-state.tsx +40 -0
  151. package/src/primitives/index.ts +118 -0
  152. package/src/primitives/input.stories.tsx +113 -0
  153. package/src/primitives/input.tsx +136 -0
  154. package/src/primitives/label.stories.tsx +84 -0
  155. package/src/primitives/label.tsx +24 -0
  156. package/src/primitives/progress.stories.tsx +93 -0
  157. package/src/primitives/progress.tsx +50 -0
  158. package/src/primitives/segmented-control.test.tsx +328 -0
  159. package/src/primitives/segmented-control.tsx +154 -0
  160. package/src/primitives/select.stories.tsx +164 -0
  161. package/src/primitives/select.tsx +158 -0
  162. package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
  163. package/src/primitives/sidebar-drop-zone.tsx +149 -0
  164. package/src/primitives/skeleton.stories.tsx +79 -0
  165. package/src/primitives/skeleton.tsx +55 -0
  166. package/src/primitives/stat-card.stories.tsx +137 -0
  167. package/src/primitives/stat-card.tsx +97 -0
  168. package/src/primitives/switch.stories.tsx +85 -0
  169. package/src/primitives/switch.tsx +28 -0
  170. package/src/primitives/table.stories.tsx +170 -0
  171. package/src/primitives/table.tsx +116 -0
  172. package/src/primitives/tabs.stories.tsx +180 -0
  173. package/src/primitives/tabs.tsx +71 -0
  174. package/src/primitives/terminal-display.stories.tsx +191 -0
  175. package/src/primitives/terminal-display.tsx +189 -0
  176. package/src/primitives/theme-toggle.stories.tsx +32 -0
  177. package/src/primitives/theme-toggle.tsx +96 -0
  178. package/src/primitives/toast.stories.tsx +155 -0
  179. package/src/primitives/toast.tsx +190 -0
  180. package/src/primitives/upload-progress.stories.tsx +120 -0
  181. package/src/primitives/upload-progress.tsx +110 -0
  182. package/src/run/expanded-tool-detail.stories.tsx +182 -0
  183. package/src/run/expanded-tool-detail.tsx +186 -0
  184. package/src/run/index.ts +13 -0
  185. package/src/run/inline-thinking-item.stories.tsx +136 -0
  186. package/src/run/inline-thinking-item.tsx +120 -0
  187. package/src/run/inline-tool-item.stories.tsx +222 -0
  188. package/src/run/inline-tool-item.tsx +190 -0
  189. package/src/run/run-group.stories.tsx +322 -0
  190. package/src/run/run-group.tsx +569 -0
  191. package/src/run/run-item-primitives.tsx +17 -0
  192. package/src/run/tool-call-feed.stories.tsx +294 -0
  193. package/src/run/tool-call-feed.tsx +192 -0
  194. package/src/run/tool-call-step.stories.tsx +198 -0
  195. package/src/run/tool-call-step.tsx +240 -0
  196. package/src/sdk-hooks.ts +38 -0
  197. package/src/stores/active-sessions-store.ts +455 -0
  198. package/src/stores/chat-store.ts +43 -0
  199. package/src/stores/index.ts +2 -0
  200. package/src/tool-previews/command-preview.tsx +116 -0
  201. package/src/tool-previews/diff-preview.tsx +85 -0
  202. package/src/tool-previews/glob-results-preview.tsx +98 -0
  203. package/src/tool-previews/grep-results-preview.tsx +157 -0
  204. package/src/tool-previews/index.ts +22 -0
  205. package/src/tool-previews/preview-primitives.tsx +84 -0
  206. package/src/tool-previews/question-preview.tsx +101 -0
  207. package/src/tool-previews/web-search-preview.tsx +117 -0
  208. package/src/tool-previews/write-file-preview.tsx +80 -0
  209. package/src/types/branding.ts +11 -0
  210. package/src/types/index.ts +5 -0
  211. package/src/types/message.ts +13 -0
  212. package/src/types/parts.ts +51 -0
  213. package/src/types/run.ts +56 -0
  214. package/src/types/tool-display.ts +41 -0
  215. package/src/utils/copy-text.ts +30 -0
  216. package/src/utils/format.test.ts +43 -0
  217. package/src/utils/format.ts +56 -0
  218. package/src/utils/index.ts +10 -0
  219. package/src/utils/time-ago.ts +9 -0
  220. package/src/utils/tool-display.ts +238 -0
@@ -0,0 +1,92 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { UserMessage } from './user-message'
3
+ import type { SessionMessage } from '../types/message'
4
+ import type { SessionPart } from '../types/parts'
5
+
6
+ const meta: Meta<typeof UserMessage> = {
7
+ title: 'Chat/UserMessage',
8
+ component: UserMessage,
9
+ parameters: {
10
+ layout: 'fullscreen',
11
+ backgrounds: { default: 'dark' },
12
+ },
13
+ decorators: [
14
+ (Story) => (
15
+ <div className="min-h-screen bg-[var(--bg-root)] p-8">
16
+ <div className="mx-auto max-w-2xl">
17
+ <Story />
18
+ </div>
19
+ </div>
20
+ ),
21
+ ],
22
+ }
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof UserMessage>
26
+
27
+ const makeMessage = (id: string): SessionMessage => ({
28
+ id,
29
+ role: 'user',
30
+ time: { created: Date.now() },
31
+ })
32
+
33
+ const textPart = (text: string): SessionPart => ({ type: 'text', text })
34
+
35
+ export const Simple: Story = {
36
+ args: {
37
+ message: makeMessage('msg-1'),
38
+ parts: [textPart('How do I implement a binary search tree in TypeScript?')],
39
+ },
40
+ }
41
+
42
+ export const Multiline: Story = {
43
+ args: {
44
+ message: makeMessage('msg-2'),
45
+ parts: [
46
+ textPart(
47
+ `I have a React component that fetches data on mount but the loading state flickers briefly even when data is cached.\n\nHere's what I'm doing:\n\n\`\`\`tsx\nconst [data, setData] = useState(null)\nconst [loading, setLoading] = useState(true)\n\nuseEffect(() => {\n fetchData().then(setData).finally(() => setLoading(false))\n}, [])\n\`\`\`\n\nWhat's the cleanest fix?`,
48
+ ),
49
+ ],
50
+ },
51
+ }
52
+
53
+ export const ShortQuestion: Story = {
54
+ args: {
55
+ message: makeMessage('msg-3'),
56
+ parts: [textPart('What is the time complexity of quicksort in the worst case?')],
57
+ },
58
+ }
59
+
60
+ export const WithMarkdown: Story = {
61
+ args: {
62
+ message: makeMessage('msg-4'),
63
+ parts: [
64
+ textPart(
65
+ `Can you refactor this so it uses **async/await** instead of promise chains?\n\nAlso make sure:\n- Error handling is explicit\n- The return type is \`Promise<Result<T, Error>>\`\n- No unhandled rejections`,
66
+ ),
67
+ ],
68
+ },
69
+ }
70
+
71
+ export const LongMessage: Story = {
72
+ args: {
73
+ message: makeMessage('msg-5'),
74
+ parts: [
75
+ textPart(
76
+ `I'm building a distributed rate limiter that needs to work across multiple Node.js instances behind a load balancer. The requirements are:\n\n1. Max 100 requests per minute per user\n2. Sliding window (not fixed)\n3. Redis as the shared store\n4. Must handle Redis failure gracefully (fail open)\n5. Sub-millisecond p99 overhead\n\nI've seen the token bucket and leaky bucket algorithms but I'm not sure which is better here. Can you walk me through the implementation in TypeScript using ioredis, and explain the trade-offs?`,
77
+ ),
78
+ ],
79
+ },
80
+ }
81
+
82
+ export const WithActions: Story = {
83
+ args: {
84
+ message: makeMessage('msg-6'),
85
+ parts: [textPart('Write a SQL query to find the top 5 customers by revenue this quarter.')],
86
+ actions: (
87
+ <button className="text-xs text-muted-foreground hover:text-foreground">
88
+ Edit
89
+ </button>
90
+ ),
91
+ },
92
+ }
@@ -0,0 +1,43 @@
1
+ import { memo, type ReactNode } from "react";
2
+ import type { SessionMessage } from "../types/message";
3
+ import type { SessionPart } from "../types/parts";
4
+
5
+ export interface UserMessageProps {
6
+ message: SessionMessage;
7
+ parts: SessionPart[];
8
+ actions?: ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Simple user message bubble.
13
+ * Renders text parts from the user's message.
14
+ */
15
+ export const UserMessage = memo(({ message, parts, actions }: UserMessageProps) => {
16
+ const textContent = parts
17
+ .filter((p) => p.type === "text")
18
+ .map((p) => (p as { text: string }).text)
19
+ .join("\n");
20
+
21
+ if (!textContent.trim()) return null;
22
+
23
+ return (
24
+ <div className="flex justify-end">
25
+ <div className="flex max-w-[78%] flex-col items-end gap-2">
26
+ <div className="w-full rounded-[26px] rounded-br-[12px] bg-[var(--brand-primary)] px-4 py-3 text-white shadow-[0_8px_20px_rgba(15,23,42,0.12)]">
27
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-white/60">
28
+ You
29
+ </div>
30
+ <div className="whitespace-pre-wrap text-[15px] leading-6.5 text-white">
31
+ {textContent}
32
+ </div>
33
+ </div>
34
+ {actions ? (
35
+ <div className="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground">
36
+ {actions}
37
+ </div>
38
+ ) : null}
39
+ </div>
40
+ </div>
41
+ );
42
+ });
43
+ UserMessage.displayName = "UserMessage";
@@ -0,0 +1,351 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useEffect, useMemo, useState } from "react";
4
+ import { PencilLine, Save, Users, Wifi, WifiOff } from "lucide-react";
5
+ import { Markdown } from "../markdown/markdown";
6
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../primitives/tabs";
7
+ import { cn } from "../lib/utils";
8
+ import { ArtifactPane, type ArtifactPaneProps } from "../primitives/artifact-pane";
9
+ import {
10
+ CollaboratorsList,
11
+ TiptapEditor,
12
+ type TiptapEditorProps,
13
+ } from "./tiptap-editor";
14
+ import {
15
+ EditorProvider,
16
+ type ConnectionState,
17
+ type EditorProviderProps,
18
+ } from "./editor-provider";
19
+ import { MarkdownDocumentEditor } from "./markdown-document-editor";
20
+ import {
21
+ htmlToMarkdown,
22
+ markdownToHtml,
23
+ normalizeMarkdown,
24
+ } from "./markdown-conversion";
25
+ import { useCollaborators, useEditorConnection } from "./use-editor";
26
+
27
+ export type DocumentEditorMode = "preview" | "edit";
28
+ export type DocumentEditorBackend = "local" | "collaborative";
29
+
30
+ export interface DocumentEditorPaneCollaborationConfig
31
+ extends Omit<EditorProviderProps, "children"> {}
32
+
33
+ export interface DocumentEditorPaneProps
34
+ extends Omit<ArtifactPaneProps, "children" | "tabs" | "toolbar" | "emptyState"> {
35
+ tabs?: ArtifactPaneProps["tabs"];
36
+ toolbar?: ReactNode;
37
+ markdown?: string;
38
+ mode?: DocumentEditorMode;
39
+ defaultMode?: DocumentEditorMode;
40
+ onModeChange?: (mode: DocumentEditorMode) => void;
41
+ backend?: DocumentEditorBackend;
42
+ placeholder?: string;
43
+ autoFocus?: boolean;
44
+ readOnly?: boolean;
45
+ onChange?: (markdown: string) => void;
46
+ onSave?: (markdown: string) => Promise<void> | void;
47
+ saving?: boolean;
48
+ saveLabel?: string;
49
+ previewClassName?: string;
50
+ editorClassName?: string;
51
+ collaboration?: DocumentEditorPaneCollaborationConfig;
52
+ }
53
+
54
+ function connectionTone(state: ConnectionState) {
55
+ switch (state) {
56
+ case "synced":
57
+ return "text-[var(--surface-success-text)] border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]";
58
+ case "connected":
59
+ case "connecting":
60
+ return "text-[var(--surface-info-text)] border-[var(--surface-info-border)] bg-[var(--surface-info-bg)]";
61
+ case "disconnected":
62
+ default:
63
+ return "text-[var(--surface-warning-text)] border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)]";
64
+ }
65
+ }
66
+
67
+ function connectionLabel(state: ConnectionState) {
68
+ switch (state) {
69
+ case "synced":
70
+ return "Live synced";
71
+ case "connected":
72
+ return "Connected";
73
+ case "connecting":
74
+ return "Connecting";
75
+ case "disconnected":
76
+ default:
77
+ return "Offline";
78
+ }
79
+ }
80
+
81
+ function connectionDescription(
82
+ state: ConnectionState,
83
+ collaborators: number,
84
+ readOnly?: boolean,
85
+ ) {
86
+ if (readOnly) {
87
+ return state === "disconnected"
88
+ ? "Live access is paused. You can keep reading while the editor reconnects."
89
+ : "You are viewing the live document in read-only mode.";
90
+ }
91
+
92
+ switch (state) {
93
+ case "synced":
94
+ return collaborators > 0
95
+ ? `You and ${collaborators} collaborator${
96
+ collaborators === 1 ? "" : "s"
97
+ } are editing the same document.`
98
+ : "You are editing the live document. Changes sync automatically.";
99
+ case "connected":
100
+ case "connecting":
101
+ return "Connecting the live document. Local edits stay in place while sync catches up.";
102
+ case "disconnected":
103
+ default:
104
+ return "Live updates are paused. You can keep editing and reconnect when the transport is healthy again.";
105
+ }
106
+ }
107
+
108
+ function CollaborativeDocumentSurface({
109
+ markdown,
110
+ placeholder,
111
+ autoFocus,
112
+ readOnly,
113
+ className,
114
+ onChange,
115
+ }: {
116
+ markdown: string;
117
+ placeholder?: string;
118
+ autoFocus?: boolean;
119
+ readOnly?: boolean;
120
+ className?: string;
121
+ onChange?: (markdown: string) => void;
122
+ }) {
123
+ const { state } = useEditorConnection();
124
+ const { collaborators } = useCollaborators();
125
+ const initialContent = useMemo(() => markdownToHtml(markdown), [markdown]);
126
+ const collaboratorCount = collaborators.length + 1;
127
+
128
+ return (
129
+ <div className={cn("flex h-full min-h-0 flex-col gap-3", className)}>
130
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-[var(--radius-lg)] border border-border bg-card px-3 py-2">
131
+ <div className="min-w-0 space-y-2">
132
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
133
+ <span
134
+ className={cn(
135
+ "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-medium",
136
+ connectionTone(state),
137
+ )}
138
+ >
139
+ {state === "disconnected" ? (
140
+ <WifiOff className="h-3.5 w-3.5" />
141
+ ) : (
142
+ <Wifi className="h-3.5 w-3.5" />
143
+ )}
144
+ {connectionLabel(state)}
145
+ </span>
146
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2.5 py-1">
147
+ <Users className="h-3.5 w-3.5" />
148
+ {collaborators.length === 0 ? "Solo editing" : `${collaboratorCount} active`}
149
+ </span>
150
+ </div>
151
+ <p className="text-xs text-muted-foreground">
152
+ {connectionDescription(state, collaborators.length, readOnly)}
153
+ </p>
154
+ </div>
155
+ <CollaboratorsList collaborators={collaborators} />
156
+ </div>
157
+
158
+ <TiptapEditor
159
+ initialContent={initialContent}
160
+ placeholder={placeholder}
161
+ autoFocus={autoFocus}
162
+ readOnly={readOnly}
163
+ className={cn("h-full min-h-[16rem]", className)}
164
+ onUpdate={(editor) => {
165
+ onChange?.(normalizeMarkdown(htmlToMarkdown(editor.getHTML())));
166
+ }}
167
+ />
168
+ </div>
169
+ );
170
+ }
171
+
172
+ /**
173
+ * DocumentEditorPane — reusable markdown document surface with preview/edit
174
+ * modes and optional collaborative editing backed by Yjs/Hocuspocus.
175
+ */
176
+ export function DocumentEditorPane({
177
+ eyebrow,
178
+ title,
179
+ subtitle,
180
+ meta,
181
+ headerActions,
182
+ footer,
183
+ className,
184
+ contentClassName,
185
+ tabs,
186
+ toolbar,
187
+ markdown = "",
188
+ mode,
189
+ defaultMode = "preview",
190
+ onModeChange,
191
+ backend = "local",
192
+ placeholder = "Start writing...",
193
+ autoFocus = false,
194
+ readOnly = false,
195
+ onChange,
196
+ onSave,
197
+ saving = false,
198
+ saveLabel = "Save changes",
199
+ previewClassName,
200
+ editorClassName,
201
+ collaboration,
202
+ }: DocumentEditorPaneProps) {
203
+ const [draft, setDraft] = useState(markdown);
204
+ const [uncontrolledMode, setUncontrolledMode] =
205
+ useState<DocumentEditorMode>(defaultMode);
206
+ const activeMode = mode ?? uncontrolledMode;
207
+ const isCollaborative = backend === "collaborative" && Boolean(collaboration);
208
+ const isDirty = normalizeMarkdown(draft) !== normalizeMarkdown(markdown);
209
+ const saveStateLabel = readOnly
210
+ ? "Read only"
211
+ : isCollaborative
212
+ ? isDirty
213
+ ? "Snapshot pending"
214
+ : "Live document current"
215
+ : isDirty
216
+ ? "Unsaved changes"
217
+ : "Saved";
218
+
219
+ useEffect(() => {
220
+ setDraft(markdown);
221
+ }, [markdown]);
222
+
223
+ useEffect(() => {
224
+ if (mode === undefined) {
225
+ setUncontrolledMode(defaultMode);
226
+ }
227
+ }, [defaultMode, mode]);
228
+
229
+ const setMode = (nextMode: DocumentEditorMode) => {
230
+ if (mode === undefined) {
231
+ setUncontrolledMode(nextMode);
232
+ }
233
+ onModeChange?.(nextMode);
234
+ };
235
+
236
+ const handleChange = (nextMarkdown: string) => {
237
+ setDraft(nextMarkdown);
238
+ onChange?.(nextMarkdown);
239
+ };
240
+
241
+ const editorToolbar = (
242
+ <div className="flex flex-wrap items-center justify-between gap-3">
243
+ <TabsList
244
+ variant="underline"
245
+ className="h-auto gap-4 border-0 bg-transparent p-0 text-muted-foreground"
246
+ >
247
+ <TabsTrigger
248
+ value="preview"
249
+ variant="underline"
250
+ className="pb-2 data-[state=active]:border-primary data-[state=active]:text-foreground"
251
+ >
252
+ Preview
253
+ </TabsTrigger>
254
+ <TabsTrigger
255
+ value="edit"
256
+ variant="underline"
257
+ className="flex items-center gap-2 pb-2 data-[state=active]:border-primary data-[state=active]:text-foreground"
258
+ >
259
+ <PencilLine className="h-3.5 w-3.5" />
260
+ {isCollaborative ? "Live edit" : "Edit"}
261
+ </TabsTrigger>
262
+ </TabsList>
263
+
264
+ <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
265
+ {toolbar}
266
+ <span className="rounded-full border border-border bg-card px-2.5 py-1 font-medium">
267
+ {isCollaborative ? "Live document" : "Local draft"}
268
+ </span>
269
+ <span className="rounded-full border border-border bg-background px-2.5 py-1">
270
+ {saveStateLabel}
271
+ </span>
272
+ {onSave && !readOnly && (
273
+ <button
274
+ type="button"
275
+ onClick={() => void onSave(draft)}
276
+ disabled={saving || !isDirty}
277
+ className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-border bg-card px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:border-primary/40 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
278
+ >
279
+ <Save className="h-3.5 w-3.5" />
280
+ {saving ? "Saving..." : saveLabel}
281
+ </button>
282
+ )}
283
+ </div>
284
+ </div>
285
+ );
286
+
287
+ const preview = (
288
+ <div
289
+ className={cn(
290
+ "rounded-[var(--radius-lg)] border border-border bg-background p-5",
291
+ previewClassName,
292
+ )}
293
+ >
294
+ <Markdown className="prose-sm max-w-none">{draft}</Markdown>
295
+ </div>
296
+ );
297
+
298
+ const localEditor = (
299
+ <MarkdownDocumentEditor
300
+ value={draft}
301
+ placeholder={placeholder}
302
+ autoFocus={autoFocus}
303
+ readOnly={readOnly}
304
+ onChange={(nextMarkdown) => {
305
+ handleChange(nextMarkdown);
306
+ }}
307
+ className={editorClassName}
308
+ />
309
+ );
310
+
311
+ const collaborativeEditor = collaboration ? (
312
+ <EditorProvider key={collaboration.documentName} {...collaboration}>
313
+ <CollaborativeDocumentSurface
314
+ markdown={draft}
315
+ placeholder={placeholder}
316
+ autoFocus={autoFocus}
317
+ readOnly={readOnly}
318
+ className={editorClassName}
319
+ onChange={handleChange}
320
+ />
321
+ </EditorProvider>
322
+ ) : localEditor;
323
+
324
+ return (
325
+ <Tabs
326
+ value={activeMode}
327
+ onValueChange={(nextValue) => setMode(nextValue as DocumentEditorMode)}
328
+ className="h-full"
329
+ >
330
+ <ArtifactPane
331
+ eyebrow={eyebrow}
332
+ title={title}
333
+ subtitle={subtitle}
334
+ meta={meta}
335
+ headerActions={headerActions}
336
+ footer={footer}
337
+ tabs={tabs}
338
+ className={className}
339
+ contentClassName={contentClassName}
340
+ toolbar={editorToolbar}
341
+ >
342
+ <TabsContent value="preview" className="mt-0 h-full px-4 py-4">
343
+ {preview}
344
+ </TabsContent>
345
+ <TabsContent value="edit" className="mt-0 h-full px-4 py-4">
346
+ {isCollaborative ? collaborativeEditor : localEditor}
347
+ </TabsContent>
348
+ </ArtifactPane>
349
+ </Tabs>
350
+ );
351
+ }